diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index cbd0231fdb..1f937e1837 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2022.1.1", + "version": "2022.2.3", "commands": [ "jb" ] @@ -21,7 +21,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2022.607.0", + "version": "2022.809.0", "commands": [ "localisation" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef729a779f..56b3ebe87b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,11 @@ on: [push, pull_request] name: Continuous Integration +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) jobs: inspect-code: @@ -82,7 +88,7 @@ jobs: run: dotnet build -c Debug -warnaserror osu.Desktop.slnf - name: Test - run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" + run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0 shell: pwsh # Attempt to upload results even if test fails. diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index 358cbda17a..bfc9620174 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -8,8 +8,12 @@ on: workflows: ["Continuous Integration"] types: - completed +permissions: {} jobs: annotate: + permissions: + checks: write # to create checks (dorny/test-reporter) + name: Annotate CI run with test results runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml index 442b97c473..cce3f23e5f 100644 --- a/.github/workflows/sentry-release.yml +++ b/.github/workflows/sentry-release.yml @@ -5,6 +5,9 @@ on: tags: - '*' +permissions: + contents: read # to fetch code (actions/checkout) + jobs: sentry_release: runs-on: ubuntu-latest 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/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 8b5431e2d6..03fd21829d 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -6,8 +6,6 @@ T:System.IComparable;Don't use non-generic IComparable. Use generic version inst T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable instead. M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900) -T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods. -T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods. T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods. M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead. @@ -17,5 +15,11 @@ M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Gen M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks. P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. Use Task.GetResultSafely() to ensure we avoid deadlocks. M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever. +M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. +M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. +M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead. +M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead. +M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead. +M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead. diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index bc285dbe11..936808f38b 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs index 00754c6346..1e88f87f09 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs @@ -77,5 +77,8 @@ namespace osu.Game.Rulesets.EmptyFreeform }; } } + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 718ada1905..35e7742172 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs index 7dcdac7019..2f6ba0dda6 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -50,9 +48,9 @@ namespace osu.Game.Rulesets.Pippidon new KeyBinding(InputKey.X, PippidonAction.Button2), }; - public override Drawable CreateIcon() => new Sprite - { - Texture = new TextureStore(new TextureLoaderStore(CreateResourceStore()), false).Get("Textures/coin"), - }; + public override Drawable CreateIcon() => new PippidonRulesetIcon(this); + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs new file mode 100644 index 0000000000..ff10233b50 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs @@ -0,0 +1,26 @@ +// 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.Allocation; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonRulesetIcon : Sprite + { + private readonly Ruleset ruleset; + + public PippidonRulesetIcon(Ruleset ruleset) + { + this.ruleset = ruleset; + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + Texture = new TextureStore(renderer, new TextureLoaderStore(ruleset.CreateResourceStore()), false).Get("Textures/coin"); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 6b9c3f4d63..c1044965b5 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs index c8f0c07724..a32586c414 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs @@ -54,5 +54,8 @@ namespace osu.Game.Rulesets.EmptyScrolling Text = ShortName[0].ToString(), Font = OsuFont.Default.With(size: 18), }; + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 718ada1905..35e7742172 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + 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/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs index d4566477db..bde530feb8 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs @@ -4,8 +4,6 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -47,10 +45,9 @@ namespace osu.Game.Rulesets.Pippidon new KeyBinding(InputKey.S, PippidonAction.MoveDown), }; - public override Drawable CreateIcon() => new Sprite - { - Margin = new MarginPadding { Top = 3 }, - Texture = new TextureStore(new TextureLoaderStore(CreateResourceStore()), false).Get("Textures/coin"), - }; + public override Drawable CreateIcon() => new PippidonRulesetIcon(this); + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs new file mode 100644 index 0000000000..6b87d93951 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs @@ -0,0 +1,29 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonRulesetIcon : Sprite + { + private readonly Ruleset ruleset; + + public PippidonRulesetIcon(Ruleset ruleset) + { + this.ruleset = ruleset; + + Margin = new MarginPadding { Top = 3 }; + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + Texture = new TextureStore(renderer, new TextureLoaderStore(ruleset.CreateResourceStore()), false).Get("Textures/coin"); + } + } +} diff --git a/osu.Android.props b/osu.Android.props index 8c15ed7949..f251e8ee71 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,11 +51,11 @@ - - + + - + diff --git a/osu.Android/AndroidJoystickSettings.cs b/osu.Android/AndroidJoystickSettings.cs new file mode 100644 index 0000000000..26e921a426 --- /dev/null +++ b/osu.Android/AndroidJoystickSettings.cs @@ -0,0 +1,76 @@ +// 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.Allocation; +using osu.Framework.Android.Input; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; + +namespace osu.Android +{ + public class AndroidJoystickSettings : SettingsSubsection + { + protected override LocalisableString Header => JoystickSettingsStrings.JoystickGamepad; + + private readonly AndroidJoystickHandler joystickHandler; + + private readonly Bindable enabled = new BindableBool(true); + + private SettingsSlider deadzoneSlider = null!; + + private Bindable handlerDeadzone = null!; + + private Bindable localDeadzone = null!; + + public AndroidJoystickSettings(AndroidJoystickHandler joystickHandler) + { + this.joystickHandler = joystickHandler; + } + + [BackgroundDependencyLoader] + private void load() + { + // use local bindable to avoid changing enabled state of game host's bindable. + handlerDeadzone = joystickHandler.DeadzoneThreshold.GetBoundCopy(); + localDeadzone = handlerDeadzone.GetUnboundCopy(); + + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = CommonStrings.Enabled, + Current = enabled + }, + deadzoneSlider = new SettingsSlider + { + LabelText = JoystickSettingsStrings.DeadzoneThreshold, + KeyboardStep = 0.01f, + DisplayAsPercentage = true, + Current = localDeadzone, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + enabled.BindTo(joystickHandler.Enabled); + enabled.BindValueChanged(e => deadzoneSlider.Current.Disabled = !e.NewValue, true); + + handlerDeadzone.BindValueChanged(val => + { + bool disabled = localDeadzone.Disabled; + + localDeadzone.Disabled = false; + localDeadzone.Value = val.NewValue; + localDeadzone.Disabled = disabled; + }, true); + + localDeadzone.BindValueChanged(val => handlerDeadzone.Value = val.NewValue); + } + } +} diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 636fc7d2df..6b88f21bcd 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -96,6 +96,9 @@ namespace osu.Android case AndroidMouseHandler mh: return new AndroidMouseSettings(mh); + case AndroidJoystickHandler jh: + return new AndroidJoystickSettings(jh); + default: return base.CreateSettingsSubsectionFor(handler); } @@ -103,9 +106,9 @@ namespace osu.Android private class AndroidBatteryInfo : BatteryInfo { - public override double ChargeLevel => Battery.ChargeLevel; + public override double? ChargeLevel => Battery.ChargeLevel; - public override bool IsCharging => Battery.PowerSource != BatteryPowerSource.Battery; + public override bool OnBattery => Battery.PowerSource == BatteryPowerSource.Battery; } } } diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index 90b02c527b..004cc8c39c 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -26,6 +26,7 @@ true + diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index d0b6953c30..9cf68d88d9 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.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; using System.Text; using DiscordRPC; @@ -26,15 +24,15 @@ namespace osu.Desktop { private const string client_id = "367827983903490050"; - private DiscordRpcClient client; + private DiscordRpcClient client = null!; [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; - private IBindable user; + private IBindable user = null!; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; private readonly IBindable status = new Bindable(); private readonly IBindable activity = new Bindable(); @@ -130,7 +128,7 @@ namespace osu.Desktop presence.Assets.LargeImageText = string.Empty; else { - if (user.Value.RulesetsStatistics != null && user.Value.RulesetsStatistics.TryGetValue(ruleset.Value.ShortName, out UserStatistics statistics)) + if (user.Value.RulesetsStatistics != null && user.Value.RulesetsStatistics.TryGetValue(ruleset.Value.ShortName, out UserStatistics? statistics)) presence.Assets.LargeImageText = $"{user.Value.Username}" + (statistics.GlobalRank > 0 ? $" (rank #{statistics.GlobalRank:N0})" : string.Empty); else presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty); @@ -164,7 +162,7 @@ namespace osu.Desktop }); } - private IBeatmapInfo getBeatmap(UserActivity activity) + private IBeatmapInfo? getBeatmap(UserActivity activity) { switch (activity) { @@ -183,10 +181,10 @@ namespace osu.Desktop switch (activity) { case UserActivity.InGame game: - return game.BeatmapInfo.ToString(); + return game.BeatmapInfo.ToString() ?? string.Empty; case UserActivity.Editing edit: - return edit.BeatmapInfo.ToString(); + return edit.BeatmapInfo.ToString() ?? string.Empty; case UserActivity.InLobby lobby: return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value; diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs index 7b0bd69363..0ad68919a2 100644 --- a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.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 - namespace osu.Desktop.LegacyIpc { /// @@ -13,7 +11,7 @@ namespace osu.Desktop.LegacyIpc /// public class LegacyIpcDifficultyCalculationRequest { - public string BeatmapFile { get; set; } + public string BeatmapFile { get; set; } = string.Empty; public int RulesetId { get; set; } public int Mods { get; set; } } diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs index 6d36cbc4b6..7b9fae5797 100644 --- a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.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 - namespace osu.Desktop.LegacyIpc { /// diff --git a/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs b/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs index 4df477191d..8d0add32d1 100644 --- a/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs +++ b/osu.Desktop/LegacyIpc/LegacyIpcMessage.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.Platform; using Newtonsoft.Json.Linq; @@ -39,17 +37,20 @@ namespace osu.Desktop.LegacyIpc public new object Value { get => base.Value; - set => base.Value = new Data - { - MessageType = value.GetType().Name, - MessageData = value - }; + set => base.Value = new Data(value.GetType().Name, value); } public class Data { - public string MessageType { get; set; } - public object MessageData { get; set; } + public string MessageType { get; } + + public object MessageData { get; } + + public Data(string messageType, object messageData) + { + MessageType = messageType; + MessageData = messageData; + } } } } diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 314a03a73e..3ee1b3da30 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -22,11 +22,15 @@ using osu.Framework.Input.Handlers; using osu.Framework.Input.Handlers.Joystick; using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Input.Handlers.Touch; using osu.Framework.Threading; using osu.Game.IO; using osu.Game.IPC; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections; using osu.Game.Overlays.Settings.Sections.Input; +using osu.Game.Utils; +using SDL2; namespace osu.Desktop { @@ -133,12 +137,13 @@ namespace osu.Desktop { base.SetHost(host); - var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); - var desktopWindow = (SDL2DesktopWindow)host.Window; + var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); + if (iconStream != null) + desktopWindow.SetIconFromStream(iconStream); + desktopWindow.CursorState |= CursorState.Hidden; - desktopWindow.SetIconFromStream(iconStream); desktopWindow.Title = Name; desktopWindow.DragDrop += f => fileDrop(new[] { f }); } @@ -156,11 +161,16 @@ namespace osu.Desktop case JoystickHandler jh: return new JoystickSettings(jh); + case TouchHandler th: + return new InputSection.HandlerSection(th); + default: return base.CreateSettingsSubsectionFor(handler); } } + protected override BatteryInfo CreateBatteryInfo() => new SDL2BatteryInfo(); + private readonly List importableFiles = new List(); private ScheduledDelegate? importSchedule; @@ -201,5 +211,23 @@ namespace osu.Desktop base.Dispose(isDisposing); osuSchemeLinkIPCChannel?.Dispose(); } + + private class SDL2BatteryInfo : BatteryInfo + { + public override double? ChargeLevel + { + get + { + SDL.SDL_GetPowerInfo(out _, out int percentage); + + if (percentage == -1) + return null; + + return percentage / 100.0; + } + } + + public override bool OnBattery => SDL.SDL_GetPowerInfo(out _, out _) == SDL.SDL_PowerState.SDL_POWERSTATE_ON_BATTERY; + } } } diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 712f300671..5a1373e040 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.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; using System.IO; using System.Runtime.Versioning; @@ -14,22 +12,47 @@ using osu.Framework.Platform; using osu.Game; using osu.Game.IPC; using osu.Game.Tournament; +using SDL2; using Squirrel; namespace osu.Desktop { public static class Program { +#if DEBUG + private const string base_game_name = @"osu-development"; +#else private const string base_game_name = @"osu"; +#endif - private static LegacyTcpIpcProvider legacyIpc; + private static LegacyTcpIpcProvider? legacyIpc; [STAThread] public static void Main(string[] args) { // run Squirrel first, as the app may exit after these run if (OperatingSystem.IsWindows()) + { + var windowsVersion = Environment.OSVersion.Version; + + // While .NET 6 still supports Windows 7 and above, we are limited by realm currently, as they choose to only support 8.1 and higher. + // See https://www.mongodb.com/docs/realm/sdk/dotnet/#supported-platforms + if (windowsVersion.Major < 6 || (windowsVersion.Major == 6 && windowsVersion.Minor <= 2)) + { + // If users running in compatibility mode becomes more of a common thing, we may want to provide better guidance or even consider + // disabling it ourselves. + // We could also better detect compatibility mode if required: + // https://stackoverflow.com/questions/10744651/how-i-can-detect-if-my-application-is-running-under-compatibility-mode#comment58183249_10744730 + SDL.SDL_ShowSimpleMessageBox(SDL.SDL_MessageBoxFlags.SDL_MESSAGEBOX_ERROR, + "Your operating system is too old to run osu!", + "This version of osu! requires at least Windows 8.1 to run.\n" + + "Please upgrade your operating system or consider using an older version of osu!.\n\n" + + "If you are running a newer version of windows, please check you don't have \"Compatibility mode\" turned on for osu!", IntPtr.Zero); + return; + } + setupSquirrel(); + } // Back up the cwd before DesktopGameHost changes it string cwd = Environment.CurrentDirectory; diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs index f0d95ba194..cc4337fb02 100644 --- a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.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; using System.Security.Principal; using osu.Framework; @@ -21,7 +19,7 @@ namespace osu.Desktop.Security public class ElevatedPrivilegesChecker : Component { [Resolved] - private INotificationOverlay notifications { get; set; } + private INotificationOverlay notifications { get; set; } = null!; private bool elevated; @@ -78,7 +76,7 @@ namespace osu.Desktop.Security private void load(OsuColour colours) { Icon = FontAwesome.Solid.ShieldAlt; - IconBackground.Colour = colours.YellowDark; + IconContent.Colour = colours.YellowDark; } } } diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 4e5f8d37b1..84bac9da7c 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -1,35 +1,28 @@ // 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.Runtime.Versioning; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; using osu.Framework.Logging; using osu.Game; -using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; -using osuTK; -using osuTK.Graphics; using Squirrel; using Squirrel.SimpleSplat; +using LogLevel = Squirrel.SimpleSplat.LogLevel; +using UpdateManager = osu.Game.Updater.UpdateManager; namespace osu.Desktop.Updater { [SupportedOSPlatform("windows")] - public class SquirrelUpdateManager : osu.Game.Updater.UpdateManager + public class SquirrelUpdateManager : UpdateManager { - private UpdateManager updateManager; - private INotificationOverlay notificationOverlay; + private Squirrel.UpdateManager? updateManager; + private INotificationOverlay notificationOverlay = null!; - public Task PrepareUpdateAsync() => UpdateManager.RestartAppWhenExited(); + public Task PrepareUpdateAsync() => Squirrel.UpdateManager.RestartAppWhenExited(); private static readonly Logger logger = Logger.GetLogger("updater"); @@ -40,6 +33,9 @@ namespace osu.Desktop.Updater private readonly SquirrelLogger squirrelLogger = new SquirrelLogger(); + [Resolved] + private OsuGameBase game { get; set; } = null!; + [BackgroundDependencyLoader] private void load(INotificationOverlay notifications) { @@ -50,12 +46,12 @@ namespace osu.Desktop.Updater protected override async Task PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false); - private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) + private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification? notification = null) { // should we schedule a retry on completion of this check? bool scheduleRecheck = true; - const string github_token = null; // TODO: populate. + const string? github_token = null; // TODO: populate. try { @@ -68,7 +64,14 @@ namespace osu.Desktop.Updater if (updatePending) { // the user may have dismissed the completion notice, so show it again. - notificationOverlay.Post(new UpdateCompleteNotification(this)); + notificationOverlay.Post(new UpdateApplicationCompleteNotification + { + Activated = () => + { + restartToApplyUpdate(); + return true; + }, + }); return true; } @@ -80,19 +83,21 @@ namespace osu.Desktop.Updater if (notification == null) { - notification = new UpdateProgressNotification(this) { State = ProgressNotificationState.Active }; + notification = new UpdateProgressNotification + { + CompletionClickAction = restartToApplyUpdate, + }; + Schedule(() => notificationOverlay.Post(notification)); } - notification.Progress = 0; - notification.Text = @"Downloading update..."; + notification.StartDownload(); try { await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f).ConfigureAwait(false); - notification.Progress = 0; - notification.Text = @"Installing update..."; + notification.StartInstall(); await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f).ConfigureAwait(false); @@ -112,9 +117,7 @@ namespace osu.Desktop.Updater else { // In the case of an error, a separate notification will be displayed. - notification.State = ProgressNotificationState.Cancelled; - notification.Close(); - + notification.FailDownload(); Logger.Error(e, @"update failed!"); } } @@ -136,84 +139,24 @@ namespace osu.Desktop.Updater return true; } + private bool restartToApplyUpdate() + { + PrepareUpdateAsync() + .ContinueWith(_ => Schedule(() => game.AttemptExit())); + return true; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); updateManager?.Dispose(); } - private class UpdateCompleteNotification : ProgressCompletionNotification - { - [Resolved] - private OsuGame game { get; set; } - - public UpdateCompleteNotification(SquirrelUpdateManager updateManager) - { - Text = @"Update ready to install. Click to restart!"; - - Activated = () => - { - updateManager.PrepareUpdateAsync() - .ContinueWith(_ => updateManager.Schedule(() => game?.AttemptExit())); - return true; - }; - } - } - - private class UpdateProgressNotification : ProgressNotification - { - private readonly SquirrelUpdateManager updateManager; - - public UpdateProgressNotification(SquirrelUpdateManager updateManager) - { - this.updateManager = updateManager; - } - - protected override Notification CreateCompletionNotification() - { - return new UpdateCompleteNotification(updateManager); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - IconContent.AddRange(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(colours.YellowDark, colours.Yellow) - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Solid.Upload, - Colour = Color4.White, - Size = new Vector2(20), - } - }); - } - - public override void Close() - { - // cancelling updates is not currently supported by the underlying updater. - // only allow dismissing for now. - - switch (State) - { - case ProgressNotificationState.Cancelled: - base.Close(); - break; - } - } - } - private class SquirrelLogger : ILogger, IDisposable { - public Squirrel.SimpleSplat.LogLevel Level { get; set; } = Squirrel.SimpleSplat.LogLevel.Info; + public LogLevel Level { get; set; } = LogLevel.Info; - public void Write(string message, Squirrel.SimpleSplat.LogLevel logLevel) + public void Write(string message, LogLevel logLevel) { if (logLevel < Level) return; diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs index 0cb4ba9c04..284d25306d 100644 --- a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.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.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,12 +12,12 @@ namespace osu.Desktop.Windows { public class GameplayWinKeyBlocker : Component { - private Bindable disableWinKey; - private IBindable localUserPlaying; - private IBindable isActive; + private Bindable disableWinKey = null!; + private IBindable localUserPlaying = null!; + private IBindable isActive = null!; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [BackgroundDependencyLoader] private void load(ILocalUserPlayInfo localUserInfo, OsuConfigManager config) diff --git a/osu.Desktop/Windows/WindowsKey.cs b/osu.Desktop/Windows/WindowsKey.cs index c69cce6200..1051e61f2f 100644 --- a/osu.Desktop/Windows/WindowsKey.cs +++ b/osu.Desktop/Windows/WindowsKey.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; using System.Runtime.InteropServices; @@ -21,7 +19,7 @@ namespace osu.Desktop.Windows private const int wm_syskeyup = 261; //Resharper disable once NotAccessedField.Local - private static LowLevelKeyboardProcDelegate keyboardHookDelegate; // keeping a reference alive for the GC + private static LowLevelKeyboardProcDelegate? keyboardHookDelegate; // keeping a reference alive for the GC private static IntPtr keyHook; [StructLayout(LayoutKind.Explicit)] diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index a4f9e2671b..3f926ed45a 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,14 +24,9 @@ - + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs b/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs index 07ffda4030..1d207d04c7 100644 --- a/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs +++ b/osu.Game.Benchmarks/BenchmarkBeatmapParsing.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.IO; using BenchmarkDotNet.Attributes; using osu.Framework.IO.Stores; diff --git a/osu.Game.Benchmarks/BenchmarkHitObject.cs b/osu.Game.Benchmarks/BenchmarkHitObject.cs new file mode 100644 index 0000000000..65c78e39b3 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkHitObject.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using BenchmarkDotNet.Attributes; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkHitObject : BenchmarkTest + { + [Params(1, 100, 1000)] + public int Count { get; set; } + + [Params(false, true)] + public bool WithBindableAccess { get; set; } + + [Benchmark] + public HitCircle[] OsuCircle() + { + var circles = new HitCircle[Count]; + + for (int i = 0; i < Count; i++) + { + circles[i] = new HitCircle(); + + if (WithBindableAccess) + { + _ = circles[i].PositionBindable; + _ = circles[i].ScaleBindable; + _ = circles[i].ComboIndexBindable; + _ = circles[i].ComboOffsetBindable; + _ = circles[i].StackHeightBindable; + _ = circles[i].LastInComboBindable; + _ = circles[i].ComboIndexWithOffsetsBindable; + _ = circles[i].IndexInCurrentComboBindable; + _ = circles[i].SamplesBindable; + _ = circles[i].StartTimeBindable; + } + else + { + _ = circles[i].Position; + _ = circles[i].Scale; + _ = circles[i].ComboIndex; + _ = circles[i].ComboOffset; + _ = circles[i].StackHeight; + _ = circles[i].LastInCombo; + _ = circles[i].ComboIndexWithOffsets; + _ = circles[i].IndexInCurrentCombo; + _ = circles[i].Samples; + _ = circles[i].StartTime; + _ = circles[i].Position; + _ = circles[i].Scale; + _ = circles[i].ComboIndex; + _ = circles[i].ComboOffset; + _ = circles[i].StackHeight; + _ = circles[i].LastInCombo; + _ = circles[i].ComboIndexWithOffsets; + _ = circles[i].IndexInCurrentCombo; + _ = circles[i].Samples; + _ = circles[i].StartTime; + } + } + + return circles; + } + + [Benchmark] + public Hit[] TaikoHit() + { + var hits = new Hit[Count]; + + for (int i = 0; i < Count; i++) + { + hits[i] = new Hit(); + + if (WithBindableAccess) + { + _ = hits[i].TypeBindable; + _ = hits[i].IsStrongBindable; + _ = hits[i].SamplesBindable; + _ = hits[i].StartTimeBindable; + } + else + { + _ = hits[i].Type; + _ = hits[i].IsStrong; + _ = hits[i].Samples; + _ = hits[i].StartTime; + } + } + + return hits; + } + + [Benchmark] + public Fruit[] CatchFruit() + { + var fruit = new Fruit[Count]; + + for (int i = 0; i < Count; i++) + { + fruit[i] = new Fruit(); + + if (WithBindableAccess) + { + _ = fruit[i].OriginalXBindable; + _ = fruit[i].XOffsetBindable; + _ = fruit[i].ScaleBindable; + _ = fruit[i].ComboIndexBindable; + _ = fruit[i].HyperDashBindable; + _ = fruit[i].LastInComboBindable; + _ = fruit[i].ComboIndexWithOffsetsBindable; + _ = fruit[i].IndexInCurrentComboBindable; + _ = fruit[i].IndexInBeatmapBindable; + _ = fruit[i].SamplesBindable; + _ = fruit[i].StartTimeBindable; + } + else + { + _ = fruit[i].OriginalX; + _ = fruit[i].XOffset; + _ = fruit[i].Scale; + _ = fruit[i].ComboIndex; + _ = fruit[i].HyperDash; + _ = fruit[i].LastInCombo; + _ = fruit[i].ComboIndexWithOffsets; + _ = fruit[i].IndexInCurrentCombo; + _ = fruit[i].IndexInBeatmap; + _ = fruit[i].Samples; + _ = fruit[i].StartTime; + } + } + + return fruit; + } + + [Benchmark] + public Note[] ManiaNote() + { + var notes = new Note[Count]; + + for (int i = 0; i < Count; i++) + { + notes[i] = new Note(); + + if (WithBindableAccess) + { + _ = notes[i].ColumnBindable; + _ = notes[i].SamplesBindable; + _ = notes[i].StartTimeBindable; + } + else + { + _ = notes[i].Column; + _ = notes[i].Samples; + _ = notes[i].StartTime; + } + } + + return notes; + } + } +} diff --git a/osu.Game.Benchmarks/BenchmarkMod.cs b/osu.Game.Benchmarks/BenchmarkMod.cs index a1d92d9a67..994300df36 100644 --- a/osu.Game.Benchmarks/BenchmarkMod.cs +++ b/osu.Game.Benchmarks/BenchmarkMod.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; using BenchmarkDotNet.Attributes; using osu.Game.Rulesets.Osu.Mods; @@ -11,7 +9,7 @@ namespace osu.Game.Benchmarks { public class BenchmarkMod : BenchmarkTest { - private OsuModDoubleTime mod; + private OsuModDoubleTime mod = null!; [Params(1, 10, 100)] public int Times { get; set; } diff --git a/osu.Game.Benchmarks/BenchmarkRealmReads.cs b/osu.Game.Benchmarks/BenchmarkRealmReads.cs index 5ffda6504e..1df77320d2 100644 --- a/osu.Game.Benchmarks/BenchmarkRealmReads.cs +++ b/osu.Game.Benchmarks/BenchmarkRealmReads.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 System.Threading; using BenchmarkDotNet.Attributes; @@ -17,9 +15,9 @@ namespace osu.Game.Benchmarks { public class BenchmarkRealmReads : BenchmarkTest { - private TemporaryNativeStorage storage; - private RealmAccess realm; - private UpdateThread updateThread; + private TemporaryNativeStorage storage = null!; + private RealmAccess realm = null!; + private UpdateThread updateThread = null!; [Params(1, 100, 1000)] public int ReadsPerFetch { get; set; } @@ -135,9 +133,9 @@ namespace osu.Game.Benchmarks [GlobalCleanup] public void Cleanup() { - realm?.Dispose(); - storage?.Dispose(); - updateThread?.Exit(); + realm.Dispose(); + storage.Dispose(); + updateThread.Exit(); } } } diff --git a/osu.Game.Benchmarks/BenchmarkRuleset.cs b/osu.Game.Benchmarks/BenchmarkRuleset.cs index de8cb13773..7d318e043b 100644 --- a/osu.Game.Benchmarks/BenchmarkRuleset.cs +++ b/osu.Game.Benchmarks/BenchmarkRuleset.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 BenchmarkDotNet.Attributes; using BenchmarkDotNet.Engines; using osu.Game.Online.API; @@ -13,9 +11,9 @@ namespace osu.Game.Benchmarks { public class BenchmarkRuleset : BenchmarkTest { - private OsuRuleset ruleset; - private APIMod apiModDoubleTime; - private APIMod apiModDifficultyAdjust; + private OsuRuleset ruleset = null!; + private APIMod apiModDoubleTime = null!; + private APIMod apiModDifficultyAdjust = null!; public override void SetUp() { diff --git a/osu.Game.Benchmarks/BenchmarkTest.cs b/osu.Game.Benchmarks/BenchmarkTest.cs index 140696e4a4..34f5edd084 100644 --- a/osu.Game.Benchmarks/BenchmarkTest.cs +++ b/osu.Game.Benchmarks/BenchmarkTest.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 BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using NUnit.Framework; diff --git a/osu.Game.Benchmarks/Program.cs b/osu.Game.Benchmarks/Program.cs index 603d8aa1b9..439ced53ab 100644 --- a/osu.Game.Benchmarks/Program.cs +++ b/osu.Game.Benchmarks/Program.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 BenchmarkDotNet.Configs; using BenchmarkDotNet.Running; diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 36ffd3b5b6..d62d422f33 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -7,9 +7,9 @@ - + - + diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs index ea17fa400c..60fb31d1e0 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs @@ -70,10 +70,17 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor [Cached] private readonly BindableBeatDivisor beatDivisor; + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + public EditorBeatmapDependencyContainer(IBeatmap beatmap, BindableBeatDivisor beatDivisor) { - editorClock = new EditorClock(beatmap, beatDivisor); this.beatDivisor = beatDivisor; + + InternalChildren = new Drawable[] + { + editorClock = new EditorClock(beatmap, beatDivisor), + Content, + }; } } } diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs b/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs index 19321a48b9..fbbfee6b60 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.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 NUnit.Framework; using osu.Framework.Utils; diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs new file mode 100644 index 0000000000..538fc7fac6 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public class TestSceneCatchModFlashlight : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.25f)] + [TestCase(1.5f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new CatchModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new CatchModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs index ffc5734f01..bbe543e73e 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.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 NUnit.Framework; using osu.Framework.Utils; diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs index 886822f9a5..3e06e78dba 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.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 NUnit.Framework; using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Objects; diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs index 3209be12d5..c01aff0aa0 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.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 NUnit.Framework; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs index 8dd6f82c57..2078e1453f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs @@ -9,7 +9,6 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; -using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs index 6ecbf58a52..a4b2b26624 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Catch.Tests }); } - private class TestSkin : DefaultSkin + private class TestSkin : TrianglesSkin { public bool FlipCatcherPlate { get; set; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs new file mode 100644 index 0000000000..cf6a8169c4 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs @@ -0,0 +1,66 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class TestSceneCatchTouchInput : OsuTestScene + { + [Test] + public void TestBasic() + { + CatchTouchInputMapper catchTouchInputMapper = null!; + + AddStep("create input overlay", () => + { + Child = new CatchInputManager(new CatchRuleset().RulesetInfo) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + catchTouchInputMapper = new CatchTouchInputMapper + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + }; + }); + + AddStep("show overlay", () => catchTouchInputMapper.Show()); + } + + [Test] + public void TestWithoutRelax() + { + AddStep("create drawable ruleset without relax mod", () => + { + Child = new DrawableCatchRuleset(new CatchRuleset(), new CatchBeatmap(), new List()); + }); + AddUntilStep("wait for load", () => Child.IsLoaded); + AddAssert("check touch input is shown", () => this.ChildrenOfType().Any()); + } + + [Test] + public void TestWithRelax() + { + AddStep("create drawable ruleset with relax mod", () => + { + Child = new DrawableCatchRuleset(new CatchRuleset(), new CatchBeatmap(), new List { new CatchModRelax() }); + }); + AddUntilStep("wait for load", () => Child.IsLoaded); + AddAssert("check touch input is not shown", () => !this.ChildrenOfType().Any()); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 956d0e0c14..2dc99077d3 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -21,7 +21,6 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; @@ -106,20 +105,37 @@ namespace osu.Game.Rulesets.Catch.Tests public void TestCatcherCatchWidth() { float halfWidth = Catcher.CalculateCatchWidth(new BeatmapDifficulty { CircleSize = 0 }) / 2; + + AddStep("move catcher to center", () => catcher.X = CatchPlayfield.CENTER_X); + + float leftPlateBounds = CatchPlayfield.CENTER_X - halfWidth; + float rightPlateBounds = CatchPlayfield.CENTER_X + halfWidth; + AddStep("catch fruit", () => { - attemptCatch(new Fruit { X = -halfWidth + 1 }); - attemptCatch(new Fruit { X = halfWidth - 1 }); + attemptCatch(new Fruit { X = leftPlateBounds + 1 }); + attemptCatch(new Fruit { X = rightPlateBounds - 1 }); }); checkPlate(2); + AddStep("miss fruit", () => { - attemptCatch(new Fruit { X = -halfWidth - 1 }); - attemptCatch(new Fruit { X = halfWidth + 1 }); + attemptCatch(new Fruit { X = leftPlateBounds - 1 }); + attemptCatch(new Fruit { X = rightPlateBounds + 1 }); }); checkPlate(2); } + [Test] + public void TestFruitClampedToCatchableRegion() + { + AddStep("catch fruit left", () => attemptCatch(new Fruit { X = -CatchPlayfield.WIDTH })); + checkPlate(1); + AddStep("move catcher to right", () => catcher.X = CatchPlayfield.WIDTH); + AddStep("catch fruit right", () => attemptCatch(new Fruit { X = CatchPlayfield.WIDTH * 2 })); + checkPlate(2); + } + [Test] public void TestFruitChangesCatcherState() { @@ -233,11 +249,9 @@ namespace osu.Game.Rulesets.Catch.Tests [Test] public void TestHitLightingColour() { - var fruitColour = SkinConfiguration.DefaultComboColours[1]; AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true)); AddStep("catch fruit", () => attemptCatch(new Fruit())); - AddAssert("correct hit lighting colour", () => - catcher.ChildrenOfType().First()?.Entry?.ObjectColour == fruitColour); + AddAssert("correct hit lighting colour", () => catcher.ChildrenOfType().First()?.Entry?.ObjectColour == this.ChildrenOfType().First().AccentColour.Value); } [Test] diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs index 7f513728af..f3161f32be 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs @@ -1,10 +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.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Objects; @@ -12,6 +12,8 @@ using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; using osuTK; using osuTK.Graphics; @@ -19,15 +21,28 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneComboCounter : CatchSkinnableTestScene { - private ScoreProcessor scoreProcessor; + private ScoreProcessor scoreProcessor = null!; private Color4 judgedObjectColour = Color4.White; + private readonly Bindable showHud = new Bindable(true); + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.CacheAs(new TestPlayer + { + ShowingOverlayComponents = { BindTarget = showHud }, + }); + } + [SetUp] public void SetUp() => Schedule(() => { scoreProcessor = new ScoreProcessor(new CatchRuleset()); + showHud.Value = true; + SetContents(_ => new CatchComboDisplay { Anchor = Anchor.Centre, @@ -51,9 +66,15 @@ namespace osu.Game.Rulesets.Catch.Tests 1f ); }); + + AddStep("set hud to never show", () => showHud.Value = false); + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 5); + + AddStep("set hud to show", () => showHud.Value = true); + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 5); } - private void performJudgement(HitResult type, Judgement judgement = null) + private void performJudgement(HitResult type, Judgement? judgement = null) { var judgedObject = new DrawableFruit(new Fruit()) { AccentColour = { Value = judgedObjectColour } }; diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index b957ade952..c9db824615 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -1,11 +1,9 @@  - - + - - + WinExe diff --git a/osu.Game.Rulesets.Catch/CatchInputManager.cs b/osu.Game.Rulesets.Catch/CatchInputManager.cs index 86f35c8cee..5b62154a34 100644 --- a/osu.Game.Rulesets.Catch/CatchInputManager.cs +++ b/osu.Game.Rulesets.Catch/CatchInputManager.cs @@ -4,11 +4,13 @@ #nullable disable using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch { + [Cached] public class CatchInputManager : RulesetInputManager { public CatchInputManager(RulesetInfo ruleset) diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index f832d99807..5c9c95827a 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -1,38 +1,37 @@ // 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.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Rulesets.Catch.Mods; -using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; +using System; using System.Collections.Generic; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Catch.Replays; -using osu.Game.Rulesets.Replays.Types; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.Graphics; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Difficulty; -using osu.Game.Rulesets.Catch.Scoring; -using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Scoring; -using System; -using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Catch.Edit; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Replays; +using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Catch.Skinning.Legacy; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch { public class CatchRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableCatchRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableCatchRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor(); @@ -42,6 +41,8 @@ namespace osu.Game.Rulesets.Catch public const string SHORT_NAME = "fruits"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.Z, CatchAction.MoveLeft), @@ -162,7 +163,7 @@ namespace osu.Game.Rulesets.Catch }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { @@ -181,7 +182,16 @@ namespace osu.Game.Rulesets.Catch public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new CatchDifficultyCalculator(RulesetInfo, beatmap); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new CatchLegacySkinTransformer(skin); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case LegacySkin: + return new CatchLegacySkinTransformer(skin); + } + + return null; + } public override PerformanceCalculator CreatePerformanceCalculator() => new CatchPerformanceCalculator(); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index b64d860417..f37479f84a 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty private float halfCatcherWidth; + public override int Version => 20220701; + public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs index 44d14ec330..8473eda663 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) { while (path.Vertices.Count < InternalChildren.Count) - RemoveInternal(InternalChildren[^1]); + RemoveInternal(InternalChildren[^1], true); while (InternalChildren.Count < path.Vertices.Count) AddInternal(new VertexPiece()); diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs index 431ba331ac..a6f1732bc1 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components .Where(h => !(h is TinyDroplet))); while (nestedHitObjects.Count < InternalChildren.Count) - RemoveInternal(InternalChildren[^1]); + RemoveInternal(InternalChildren[^1], true); while (InternalChildren.Count < nestedHitObjects.Count) AddInternal(new FruitOutline()); diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs index 58f493b4b8..a0a11424d0 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs @@ -36,5 +36,7 @@ namespace osu.Game.Rulesets.Catch.Edit return base.CreateHitObjectBlueprintFor(hitObject); } + + protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); } } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs new file mode 100644 index 0000000000..0a0f91c781 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs @@ -0,0 +1,49 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchEditorPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + { + protected override Container Content => content; + private readonly Container content; + + public CatchEditorPlayfieldAdjustmentContainer() + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + Size = new Vector2(0.8f, 0.9f); + + InternalChild = new ScalingContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = content = new Container { RelativeSizeAxes = Axes.Both }, + }; + } + + private class ScalingContainer : Container + { + public ScalingContainer() + { + RelativeSizeAxes = Axes.Y; + Width = CatchPlayfield.WIDTH; + } + + protected override void Update() + { + base.Update(); + + Scale = new Vector2(Math.Min(Parent.ChildSize.X / CatchPlayfield.WIDTH, Parent.ChildSize.Y / CatchPlayfield.HEIGHT)); + Height = 1 / Scale.Y; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index f31dc3ef9c..54d50b01c4 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -12,8 +12,10 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Edit; @@ -37,6 +39,12 @@ namespace osu.Game.Rulesets.Catch.Edit private InputManager inputManager; + private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1) + { + MinValue = 1, + MaxValue = 10, + }; + public CatchHitObjectComposer(CatchRuleset ruleset) : base(ruleset) { @@ -51,7 +59,10 @@ namespace osu.Game.Rulesets.Catch.Edit LayerBelowRuleset.Add(new PlayfieldBorder { - RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = CatchPlayfield.HEIGHT, PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }); @@ -77,8 +88,30 @@ namespace osu.Game.Rulesets.Catch.Edit updateDistanceSnapGrid(); } + public override bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + // Note that right now these are hard to use as the default key bindings conflict with existing editor key bindings. + // In the future we will want to expose this via UI and potentially change the key bindings to be editor-specific. + // May be worth considering standardising "zoom" behaviour with what the timeline uses (ie. alt-wheel) but that may cause new conflicts. + case GlobalAction.IncreaseScrollSpeed: + this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint); + break; + + case GlobalAction.DecreaseScrollSpeed: + this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint); + break; + } + + return base.OnPressed(e); + } + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => - new DrawableCatchEditorRuleset(ruleset, beatmap, mods); + new DrawableCatchEditorRuleset(ruleset, beatmap, mods) + { + TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, } + }; protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs index c81afafae5..67238f66d4 100644 --- a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs +++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs @@ -4,6 +4,7 @@ #nullable disable using System.Collections.Generic; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -13,11 +14,24 @@ namespace osu.Game.Rulesets.Catch.Edit { public class DrawableCatchEditorRuleset : DrawableCatchRuleset { + public readonly BindableDouble TimeRangeMultiplier = new BindableDouble(1); + public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { } + protected override void Update() + { + base.Update(); + + double gamePlayTimeRange = GetTimeRange(Beatmap.Difficulty.ApproachRate); + float playfieldStretch = Playfield.DrawHeight / CatchPlayfield.HEIGHT; + TimeRange.Value = gamePlayTimeRange * TimeRangeMultiplier.Value * playfieldStretch; + } + protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty); + + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchEditorPlayfieldAdjustmentContainer(); } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs index c5ca595fd6..50e48101d3 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.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 osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Replays; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs index 10a0809e05..7eda6b37d3 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.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 osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs b/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs index 904656993e..9624e84018 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModClassic.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs index 8d4b57c244..cae19e9468 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 6927d7953f..e59a0a0431 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.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 osu.Framework.Bindables; using osu.Game.Beatmaps; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs index 4db0a53005..57c06e1cd1 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs @@ -1,14 +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 osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { public class CatchModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => 1.06; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs index bea9b094fa..cac5b9aa6a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs @@ -1,14 +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 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/CatchModFlashlight.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs index e05932ca03..a9e9e8fbd5 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs @@ -1,11 +1,8 @@ // 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.Graphics; -using osu.Game.Configuration; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -16,30 +13,22 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModFlashlight : ModFlashlight { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 1.5f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 350; + public override float DefaultFlashlightSize => 325; protected override Flashlight CreateFlashlight() => new CatchFlashlight(this, playfield); - private CatchPlayfield playfield; + private CatchPlayfield playfield = null!; public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { @@ -55,7 +44,19 @@ namespace osu.Game.Rulesets.Catch.Mods : base(modFlashlight) { this.playfield = playfield; - FlashlightSize = new Vector2(0, GetSizeFor(0)); + + FlashlightSize = new Vector2(0, GetSize()); + FlashlightSmoothness = 1.4f; + } + + protected override float GetComboScaleFor(int combo) + { + if (combo >= 200) + return 0.770f; + if (combo >= 100) + return 0.885f; + + return 1.0f; } protected override void Update() @@ -65,9 +66,9 @@ namespace osu.Game.Rulesets.Catch.Mods FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs index 1fe892c9b5..e12181d051 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs @@ -1,10 +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 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; @@ -16,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/CatchModHalfTime.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.cs index 0c7886be10..ce06b841aa 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs index 7db9bf2dfd..93eadcc13e 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.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.Game.Rulesets.Mods; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps; @@ -11,7 +9,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) { diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index c02eedf936..d68430b64f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -1,10 +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 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; @@ -16,8 +15,8 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset { - public override string Description => @"Play with fading fruits."; - public override double ScoreMultiplier => 1.06; + 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; private const double fade_out_duration_multiplier = 0.44; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs index 89fc40356d..4cd2efdc2f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs @@ -1,9 +1,8 @@ // 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 osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Objects; @@ -16,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/CatchModMuted.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs index 6b28d1a127..6d2565440a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMuted.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.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs index 365d987794..9e38913be7 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.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.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Mods; @@ -10,6 +8,6 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModNightcore : ModNightcore { - public override double ScoreMultiplier => 1.06; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNoFail.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNoFail.cs index 89e7e4bcd6..3c02646e99 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNoFail.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNoFail.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs index 385d4c50c0..19b4a39f97 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs @@ -1,14 +1,11 @@ // 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.Localisation; using osu.Game.Rulesets.Mods; using osu.Framework.Utils; -using osu.Game.Configuration; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.UI; @@ -16,17 +13,10 @@ 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", - "The combo count at which the catcher becomes completely hidden", - SettingControlType = typeof(SettingsSlider) - )] - public override BindableInt HiddenComboCount { get; } = new BindableInt + public override BindableInt HiddenComboCount { get; } = new BindableInt(10) { - Default = 10, - Value = 10, MinValue = 0, MaxValue = 50, }; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs b/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs index 0a74ee4fbb..fb92399102 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index f4d6fb9ab3..3f7560844c 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -1,12 +1,11 @@ // 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.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; @@ -18,19 +17,22 @@ 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; + private DrawableCatchRuleset drawableRuleset = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - this.drawableRuleset = drawableRuleset; + this.drawableRuleset = (DrawableCatchRuleset)drawableRuleset; } public void ApplyToPlayer(Player player) { if (!drawableRuleset.HasReplayLoaded.Value) - drawableRuleset.Cursor.Add(new MouseInputHelper((CatchPlayfield)drawableRuleset.Playfield)); + { + var catchPlayfield = (CatchPlayfield)drawableRuleset.Playfield; + catchPlayfield.CatcherArea.Add(new MouseInputHelper(catchPlayfield.CatcherArea)); + } } private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition @@ -39,9 +41,10 @@ namespace osu.Game.Rulesets.Catch.Mods public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public MouseInputHelper(CatchPlayfield playfield) + public MouseInputHelper(CatcherArea catcherArea) { - catcherArea = playfield.CatcherArea; + this.catcherArea = catcherArea; + RelativeSizeAxes = Axes.Both; } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModSuddenDeath.cs b/osu.Game.Rulesets.Catch/Mods/CatchModSuddenDeath.cs index d98829137c..68e01391ce 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModSuddenDeath.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModSuddenDeath.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index f5a3426305..cd2b8348e2 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -19,7 +20,9 @@ namespace osu.Game.Rulesets.Catch.Objects { public const float OBJECT_RADIUS = 64; - public readonly Bindable OriginalXBindable = new Bindable(); + private HitObjectProperty originalX; + + public Bindable OriginalXBindable => originalX.Bindable; /// /// The horizontal position of the hit object between 0 and . @@ -31,18 +34,20 @@ namespace osu.Game.Rulesets.Catch.Objects [JsonIgnore] public float X { - set => OriginalXBindable.Value = value; + set => originalX.Value = value; } - public readonly Bindable XOffsetBindable = new Bindable(); + private HitObjectProperty xOffset; + + public Bindable XOffsetBindable => xOffset.Bindable; /// /// A random offset applied to the horizontal position, set by the beatmap processing. /// public float XOffset { - get => XOffsetBindable.Value; - set => XOffsetBindable.Value = value; + get => xOffset.Value; + set => xOffset.Value = value; } /// @@ -54,8 +59,8 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float OriginalX { - get => OriginalXBindable.Value; - set => OriginalXBindable.Value = value; + get => originalX.Value; + set => originalX.Value = value; } /// @@ -65,63 +70,75 @@ namespace osu.Game.Rulesets.Catch.Objects /// This value is the original value plus the offset applied by the beatmap processing. /// Use if a value not affected by the offset is desired. /// - public float EffectiveX => OriginalX + XOffset; + public float EffectiveX => Math.Clamp(OriginalX + XOffset, 0, CatchPlayfield.WIDTH); public double TimePreempt { get; set; } = 1000; - public readonly Bindable IndexInBeatmapBindable = new Bindable(); + private HitObjectProperty indexInBeatmap; + + public Bindable IndexInBeatmapBindable => indexInBeatmap.Bindable; public int IndexInBeatmap { - get => IndexInBeatmapBindable.Value; - set => IndexInBeatmapBindable.Value = value; + get => indexInBeatmap.Value; + set => indexInBeatmap.Value = value; } public virtual bool NewCombo { get; set; } public int ComboOffset { get; set; } - public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); + private HitObjectProperty indexInCurrentCombo; + + public Bindable IndexInCurrentComboBindable => indexInCurrentCombo.Bindable; public int IndexInCurrentCombo { - get => IndexInCurrentComboBindable.Value; - set => IndexInCurrentComboBindable.Value = value; + get => indexInCurrentCombo.Value; + set => indexInCurrentCombo.Value = value; } - public Bindable ComboIndexBindable { get; } = new Bindable(); + private HitObjectProperty comboIndex; + + public Bindable ComboIndexBindable => comboIndex.Bindable; public int ComboIndex { - get => ComboIndexBindable.Value; - set => ComboIndexBindable.Value = value; + get => comboIndex.Value; + set => comboIndex.Value = value; } - public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + private HitObjectProperty comboIndexWithOffsets; + + public Bindable ComboIndexWithOffsetsBindable => comboIndexWithOffsets.Bindable; public int ComboIndexWithOffsets { - get => ComboIndexWithOffsetsBindable.Value; - set => ComboIndexWithOffsetsBindable.Value = value; + get => comboIndexWithOffsets.Value; + set => comboIndexWithOffsets.Value = value; } - public Bindable LastInComboBindable { get; } = new Bindable(); + private HitObjectProperty lastInCombo; + + public Bindable LastInComboBindable => lastInCombo.Bindable; /// /// The next fruit starts a new combo. Used for explodey. /// public virtual bool LastInCombo { - get => LastInComboBindable.Value; - set => LastInComboBindable.Value = value; + get => lastInCombo.Value; + set => lastInCombo.Value = value; } - public readonly Bindable ScaleBindable = new Bindable(1); + private HitObjectProperty scale = new HitObjectProperty(1); + + public Bindable ScaleBindable => scale.Bindable; public float Scale { - get => ScaleBindable.Value; - set => ScaleBindable.Value = value; + get => scale.Value; + set => scale.Value = value; } /// diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 311e15116e..015457e84f 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -11,6 +11,7 @@ using Newtonsoft.Json; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -84,8 +85,8 @@ namespace osu.Game.Rulesets.Catch.Objects AddNested(new TinyDroplet { StartTime = t + lastEvent.Value.Time, - X = OriginalX + Path.PositionAt( - lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X, + X = ClampToPlayfield(EffectiveX + Path.PositionAt( + lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X), }); } } @@ -102,7 +103,7 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = dropletSamples, StartTime = e.Time, - X = OriginalX + Path.PositionAt(e.PathProgress).X, + X = ClampToPlayfield(EffectiveX + Path.PositionAt(e.PathProgress).X), }); break; @@ -113,14 +114,16 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = this.GetNodeSamples(nodeIndex++), StartTime = e.Time, - X = OriginalX + Path.PositionAt(e.PathProgress).X, + X = ClampToPlayfield(EffectiveX + Path.PositionAt(e.PathProgress).X), }); break; } } } - public float EndX => OriginalX + this.CurvePositionAt(1).X; + public float EndX => ClampToPlayfield(EffectiveX + this.CurvePositionAt(1).X); + + public float ClampToPlayfield(float value) => Math.Clamp(value, 0, CatchPlayfield.WIDTH); [JsonIgnore] public double Duration diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs index 1ededa1438..c9bc9ca2ac 100644 --- a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; using osuTK.Graphics; @@ -24,12 +25,14 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float DistanceToHyperDash { get; set; } - public readonly Bindable HyperDashBindable = new Bindable(); + private HitObjectProperty hyperDash; + + public Bindable HyperDashBindable => hyperDash.Bindable; /// /// Whether this fruit can initiate a hyperdash. /// - public bool HyperDash => HyperDashBindable.Value; + public bool HyperDash => hyperDash.Value; private CatchHitObject hyperDashTarget; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index c5e5e59dd2..a73b34c9b6 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osuTK.Graphics; @@ -14,6 +13,10 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public class CatchLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasPear; + + private bool hasPear => GetTexture("fruit-pear") != null; + /// /// For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. /// @@ -50,7 +53,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy switch (catchSkinComponent.Component) { case CatchSkinComponents.Fruit: - if (GetTexture("fruit-pear") != null) + if (hasPear) return new LegacyFruitPiece(); return null; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs index b2dd29841b..b4d29988d9 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.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.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.UI; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs index 0630de9156..8f46bdbe6e 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy [BackgroundDependencyLoader] private void load(SkinManager skins) { - var defaultLegacySkin = skins.DefaultLegacySkin; + var defaultLegacySkin = skins.DefaultClassicSkin; // sprite names intentionally swapped to match stable member naming / ease of cross-referencing explosion1.Texture = defaultLegacySkin.GetTexture("scoreboard-explosion-2"); diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs index e9c289e46a..a5b7d8d0af 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.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. -#nullable disable - -using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK.Graphics; @@ -19,14 +20,29 @@ namespace osu.Game.Rulesets.Catch.UI { private int currentCombo; - [CanBeNull] - public ICatchComboCounter ComboCounter => Drawable as ICatchComboCounter; + public ICatchComboCounter? ComboCounter => Drawable as ICatchComboCounter; + + private readonly IBindable showCombo = new BindableBool(true); public CatchComboDisplay() : base(new CatchSkinComponent(CatchSkinComponents.CatchComboCounter), _ => Empty()) { } + [Resolved(canBeNull: true)] + private Player? player { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (player != null) + { + showCombo.BindTo(player.ShowingOverlayComponents); + showCombo.BindValueChanged(s => this.FadeTo(s.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + } + } + protected override void SkinChanged(ISkinSource skin) { base.SkinChanged(skin); diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index dad22fbe69..ce000b0fad 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -23,6 +23,12 @@ namespace osu.Game.Rulesets.Catch.UI /// public const float WIDTH = 512; + /// + /// The height of the playfield. + /// This doesn't include the catcher area. + /// + public const float HEIGHT = 384; + /// /// The center position of the playfield. /// diff --git a/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs new file mode 100644 index 0000000000..e6736d6c93 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs @@ -0,0 +1,277 @@ +// 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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatchTouchInputMapper : VisibilityContainer + { + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + + private readonly Dictionary trackedActionSources = new Dictionary(); + + private KeyBindingContainer keyBindingContainer = null!; + + private Container mainContent = null!; + + private InputArea leftBox = null!; + private InputArea rightBox = null!; + private InputArea leftDashBox = null!; + private InputArea rightDashBox = null!; + + [BackgroundDependencyLoader] + private void load(CatchInputManager catchInputManager, OsuColour colours) + { + const float width = 0.15f; + + keyBindingContainer = catchInputManager.KeyBindingContainer; + + RelativeSizeAxes = Axes.Both; + + Children = new Drawable[] + { + mainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Width = width, + Children = new Drawable[] + { + leftDashBox = new InputArea(TouchCatchAction.DashLeft, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + }, + leftBox = new InputArea(TouchCatchAction.MoveLeft, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = colours.Gray9, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Width = width, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + rightBox = new InputArea(TouchCatchAction.MoveRight, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = colours.Gray9, + }, + rightDashBox = new InputArea(TouchCatchAction.DashRight, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + }, + }, + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // Hide whenever the keyboard is used. + Hide(); + return false; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + return updateAction(e.Button, getTouchCatchActionFromInput(e.ScreenSpaceMousePosition)); + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + return updateAction(e.Touch.Source, getTouchCatchActionFromInput(e.ScreenSpaceTouch.Position)); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Show(); + + TouchCatchAction? action = getTouchCatchActionFromInput(e.ScreenSpaceMousePosition); + + // multiple mouse buttons may be pressed and handling the same action. + foreach (MouseButton button in e.PressedButtons) + updateAction(button, action); + + return false; + } + + protected override void OnTouchMove(TouchMoveEvent e) + { + updateAction(e.Touch.Source, getTouchCatchActionFromInput(e.ScreenSpaceTouch.Position)); + base.OnTouchMove(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + updateAction(e.Button, null); + base.OnMouseUp(e); + } + + protected override void OnTouchUp(TouchUpEvent e) + { + updateAction(e.Touch.Source, null); + base.OnTouchUp(e); + } + + private bool updateAction(object source, TouchCatchAction? newAction) + { + TouchCatchAction? actionBefore = null; + + if (trackedActionSources.TryGetValue(source, out TouchCatchAction found)) + actionBefore = found; + + if (actionBefore != newAction) + { + if (newAction != null) + trackedActionSources[source] = newAction.Value; + else + trackedActionSources.Remove(source); + + updatePressedActions(); + } + + return newAction != null; + } + + private void updatePressedActions() + { + Show(); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashLeft) || trackedActionSources.ContainsValue(TouchCatchAction.MoveLeft)) + keyBindingContainer.TriggerPressed(CatchAction.MoveLeft); + else + keyBindingContainer.TriggerReleased(CatchAction.MoveLeft); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashRight) || trackedActionSources.ContainsValue(TouchCatchAction.MoveRight)) + keyBindingContainer.TriggerPressed(CatchAction.MoveRight); + else + keyBindingContainer.TriggerReleased(CatchAction.MoveRight); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashLeft) || trackedActionSources.ContainsValue(TouchCatchAction.DashRight)) + keyBindingContainer.TriggerPressed(CatchAction.Dash); + else + keyBindingContainer.TriggerReleased(CatchAction.Dash); + } + + private TouchCatchAction? getTouchCatchActionFromInput(Vector2 screenSpaceInputPosition) + { + if (leftDashBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.DashLeft; + if (rightDashBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.DashRight; + if (leftBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.MoveLeft; + if (rightBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.MoveRight; + + return null; + } + + protected override void PopIn() => mainContent.FadeIn(300, Easing.OutQuint); + + protected override void PopOut() => mainContent.FadeOut(300, Easing.OutQuint); + + private class InputArea : CompositeDrawable, IKeyBindingHandler + { + private readonly TouchCatchAction handledAction; + + private readonly Box highlightOverlay; + + private readonly IEnumerable> trackedActions; + + private bool isHighlighted; + + public InputArea(TouchCatchAction handledAction, IEnumerable> trackedActions) + { + this.handledAction = handledAction; + this.trackedActions = trackedActions; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.15f, + }, + highlightOverlay = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + } + } + } + }; + } + + public bool OnPressed(KeyBindingPressEvent _) + { + updateHighlight(); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent _) + { + updateHighlight(); + } + + private void updateHighlight() + { + bool isHandling = trackedActions.Any(a => a.Value == handledAction); + + if (isHandling == isHighlighted) + return; + + isHighlighted = isHandling; + highlightOverlay.FadeTo(isHighlighted ? 0.1f : 0, isHighlighted ? 80 : 400, Easing.OutQuint); + } + } + + public enum TouchCatchAction + { + MoveLeft, + MoveRight, + DashLeft, + DashRight, + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index efc841dfac..61fefcfd99 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -271,8 +271,8 @@ namespace osu.Game.Rulesets.Catch.UI SetHyperDashState(); } - caughtObjectContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject); - droppedObjectTarget.RemoveAll(d => d.HitObject == drawableObject.HitObject); + caughtObjectContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject, false); + droppedObjectTarget.RemoveAll(d => d.HitObject == drawableObject.HitObject, false); } /// @@ -430,7 +430,7 @@ namespace osu.Game.Rulesets.Catch.UI { var droppedObject = getDroppedObject(caughtObject); - caughtObjectContainer.Remove(caughtObject); + caughtObjectContainer.Remove(caughtObject, false); droppedObjectTarget.Add(droppedObject); diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs index 6ab6a59293..d255937fed 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs @@ -93,15 +93,15 @@ namespace osu.Game.Rulesets.Catch.UI switch (entry.Animation) { case CatcherTrailAnimation.Dashing: - dashTrails.Remove(drawable); + dashTrails.Remove(drawable, false); break; case CatcherTrailAnimation.HyperDashing: - hyperDashTrails.Remove(drawable); + hyperDashTrails.Remove(drawable, false); break; case CatcherTrailAnimation.HyperDashAfterImage: - hyperDashAfterImages.Remove(drawable); + hyperDashAfterImages.Remove(drawable, false); break; } } diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index b6cea92173..e02b915508 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -4,6 +4,8 @@ #nullable disable using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -29,9 +31,19 @@ namespace osu.Game.Rulesets.Catch.UI : base(ruleset, beatmap, mods) { Direction.Value = ScrollingDirection.Down; - TimeRange.Value = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450); + TimeRange.Value = GetTimeRange(beatmap.Difficulty.ApproachRate); } + [BackgroundDependencyLoader] + private void load() + { + // With relax mod, input maps directly to x position and left/right buttons are not used. + if (!Mods.Any(m => m is ModRelax)) + KeyBindingInputManager.Add(new CatchTouchInputMapper()); + } + + protected double GetTimeRange(float approachRate) => IBeatmapDifficultyInfo.DifficultyRange(approachRate, 1800, 1200, 450); + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield); diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs index 6e6e83f9cf..0e4f612999 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; @@ -30,15 +31,18 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Cached(typeof(IScrollingInfo))] private IScrollingInfo scrollingInfo; + [Cached] + private readonly StageDefinition stage = new StageDefinition(5); + protected ManiaPlacementBlueprintTestScene() { scrollingInfo = ((ScrollingTestContainer)HitObjectContainer).ScrollingInfo; - Add(column = new Column(0) + Add(column = new Column(0, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AccentColour = Color4.OrangeRed, + AccentColour = { Value = Color4.OrangeRed }, Clock = new FramedClock(new StopwatchClock()), // No scroll }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs index 679a15e8cb..4cadcf138b 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor protected ManiaSelectionBlueprintTestScene(int columns) { - var stageDefinitions = new List { new StageDefinition { Columns = columns } }; + var stageDefinitions = new List { new StageDefinition(columns) }; base.Content.Child = scrollingTestContainer = new ScrollingTestContainer(ScrollingDirection.Up) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs index ec96205067..ef140995ec 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor private ScrollingTestContainer.TestScrollingInfo scrollingInfo = new ScrollingTestContainer.TestScrollingInfo(); [Cached(typeof(EditorBeatmap))] - private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition()) + private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition(2)) { BeatmapInfo = { @@ -56,8 +56,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { Playfield = new ManiaPlayfield(new List { - new StageDefinition { Columns = 4 }, - new StageDefinition { Columns = 3 } + new StageDefinition(4), + new StageDefinition(3) }) { Clock = new FramedClock(new StopwatchClock()) diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs index 15b38b39ba..e082b90d3b 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Database; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; @@ -34,10 +35,14 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { AddStep("setup compose screen", () => { - var editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + var beatmap = new ManiaBeatmap(new StageDefinition(4)) { BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, - }); + }; + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + + var editorBeatmap = new EditorBeatmap(beatmap, new LegacyBeatmapSkin(beatmap.BeatmapInfo, null)); Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); @@ -50,7 +55,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor (typeof(IBeatSnapProvider), editorBeatmap), (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Green)), }, - Child = new ComposeScreen { State = { Value = Visibility.Visible } }, + Children = new Drawable[] + { + editorBeatmap, + new ComposeScreen { State = { Value = Visibility.Visible } }, + } }; }); @@ -60,7 +69,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Test] public void TestDefaultSkin() { - AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLiveUnmanaged()); + AddStep("set default skin", () => skins.CurrentSkinInfo.Value = TrianglesSkin.CreateInfo().ToLiveUnmanaged()); } [Test] diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs index 4ad27348fc..a3985be936 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor public void Setup() => Schedule(() => { BeatDivisor.Value = 8; - Clock.Seek(0); + EditorClock.Seek(0); Child = composer = new TestComposer { RelativeSizeAxes = Axes.Both }; }); @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); originalTime = lastObject.HitObject.StartTime; - Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + EditorClock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); }); AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); @@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); originalTime = lastObject.HitObject.StartTime; - Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + EditorClock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); }); AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); @@ -125,7 +125,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor AddStep("seek to last object", () => { lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); - Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + EditorClock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); }); AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); @@ -205,7 +205,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { InternalChildren = new Drawable[] { - EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition(4)) { BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo } }), diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs deleted file mode 100644 index e53deb5269..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs +++ /dev/null @@ -1,52 +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 osu.Game.Rulesets.Mania.Beatmaps; -using NUnit.Framework; - -namespace osu.Game.Rulesets.Mania.Tests -{ - [TestFixture] - public class ManiaColumnTypeTest - { - [TestCase(new[] - { - ColumnType.Special - }, 1)] - [TestCase(new[] - { - ColumnType.Odd, - ColumnType.Even, - ColumnType.Even, - ColumnType.Odd - }, 4)] - [TestCase(new[] - { - ColumnType.Odd, - ColumnType.Even, - ColumnType.Odd, - ColumnType.Special, - ColumnType.Odd, - ColumnType.Even, - ColumnType.Odd - }, 7)] - public void Test(IEnumerable expected, int columns) - { - var definition = new StageDefinition - { - Columns = columns - }; - var results = getResults(definition); - Assert.AreEqual(expected, results); - } - - private IEnumerable getResults(StageDefinition definition) - { - for (int i = 0; i < definition.Columns; i++) - yield return definition.GetTypeOfColumn(i); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index 9e4db1f1c9..4ae6cb9c7c 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -16,11 +16,11 @@ namespace osu.Game.Rulesets.Mania.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; - [TestCase(2.3449735700206298d, 242, "diffcalc-test")] + [TestCase(2.3493769750220914d, 242, "diffcalc-test")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(2.7879104989252959d, 242, "diffcalc-test")] + [TestCase(2.797245912537965d, 242, "diffcalc-test")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime()); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs index eb380c07a6..e456659ac4 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs @@ -3,9 +3,11 @@ #nullable disable +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests @@ -37,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests { } - protected override void ReloadMappings() + protected override void ReloadMappings(IQueryable realmKeyBindings) { KeyBindings = DefaultKeyBindings; } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs index b64006316e..7d1a934456 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCase(ManiaAction.Key8)] public void TestEncodeDecodeSingleStage(params ManiaAction[] actions) { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 9 }); + var beatmap = new ManiaBeatmap(new StageDefinition(9)); var frame = new ManiaReplayFrame(0, actions); var legacyFrame = frame.ToLegacy(beatmap); @@ -38,8 +38,8 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCase(ManiaAction.Key8)] public void TestEncodeDecodeDualStage(params ManiaAction[] actions) { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 5 }); - beatmap.Stages.Add(new StageDefinition { Columns = 5 }); + var beatmap = new ManiaBeatmap(new StageDefinition(5)); + beatmap.Stages.Add(new StageDefinition(5)); var frame = new ManiaReplayFrame(0, actions); var legacyFrame = frame.ToLegacy(beatmap); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs new file mode 100644 index 0000000000..3bd654e75e --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs @@ -0,0 +1,49 @@ +// 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 osu.Game.Rulesets.Mania.Beatmaps; +using NUnit.Framework; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaSpecialColumnTest + { + [TestCase(new[] + { + true + }, 1)] + [TestCase(new[] + { + false, + false, + false, + false + }, 4)] + [TestCase(new[] + { + false, + false, + false, + true, + false, + false, + false + }, 7)] + public void Test(IEnumerable special, int columns) + { + var definition = new StageDefinition(columns); + var results = getResults(definition); + Assert.AreEqual(special, results); + } + + private IEnumerable getResults(StageDefinition definition) + { + for (int i = 0; i < definition.Columns; i++) + yield return definition.IsSpecialColumn(i); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs index 538c8b13d1..60363aaeef 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.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.Allocation; diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs new file mode 100644 index 0000000000..0e222fea89 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModFlashlight : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.5f)] + [TestCase(3f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new ManiaModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new ManiaModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs index 4222be0359..d27a79c41d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.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.Game.Beatmaps; @@ -87,7 +85,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods private static ManiaBeatmap createRawBeatmap() { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.ControlPointInfo.Add(0.0, new TimingControlPoint { BeatLength = 1000 }); // Set BPM to 60 // Add test hit objects diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs index 4c97f65b07..f2cc254e38 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.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 NUnit.Framework; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Tests.Visual; diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs index 9612543483..2e3b21aed7 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.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 NUnit.Framework; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mania.Objects; diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs index 1a3513d46c..d3e90170b2 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests.Skinning { @@ -24,15 +23,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [Cached] private readonly Column column; + [Cached] + private readonly StageDefinition stageDefinition = new StageDefinition(5); + public ColumnTestContainer(int column, ManiaAction action, bool showColumn = false) { InternalChildren = new[] { - this.column = new Column(column) + this.column = new Column(column, false) { Action = { Value = action }, - AccentColour = Color4.Orange, - ColumnType = column % 2 == 0 ? ColumnType.Even : ColumnType.Odd, Alpha = showColumn ? 1 : 0 }, content = new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs index fd82041ad8..75175c43d8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs @@ -61,7 +61,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning c.Add(CreateHitObject().With(h => { h.HitObject.StartTime = Time.Current + 5000; - h.AccentColour.Value = Color4.Orange; })); }) }, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs index 9f235689b4..2c5535a65f 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling.Algorithms; using osu.Game.Tests.Visual; @@ -24,6 +25,9 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [Cached(Type = typeof(IScrollingInfo))] private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo(); + [Cached] + private readonly StageDefinition stage = new StageDefinition(4); + protected override Ruleset CreateRulesetForSkinProvider() => new ManiaRuleset(); protected ManiaSkinnableTestScene() diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs new file mode 100644 index 0000000000..1bfe55b074 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs @@ -0,0 +1,52 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneBarLine : ManiaSkinnableTestScene + { + [Test] + public void TestMinor() + { + AddStep("Create barlines", () => recreate()); + } + + private void recreate(Func>? createBarLines = null) + { + var stageDefinitions = new List + { + new StageDefinition(4), + }; + + SetContents(_ => new ManiaPlayfield(stageDefinitions).With(s => + { + if (createBarLines != null) + { + var barLines = createBarLines(); + + foreach (var b in barLines) + s.Add(b); + + return; + } + + for (int i = 0; i < 64; i++) + { + s.Add(new BarLine + { + StartTime = Time.Current + i * 500, + Major = i % 4 == 0, + }); + } + })); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs deleted file mode 100644 index bbbd7edb7b..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs +++ /dev/null @@ -1,51 +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.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Mania.UI.Components; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Mania.Tests.Skinning -{ - public class TestSceneKeyArea : ManiaSkinnableTestScene - { - [BackgroundDependencyLoader] - private void load() - { - SetContents(_ => new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.8f), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new ColumnTestContainer(0, ManiaAction.Key1) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) - { - RelativeSizeAxes = Axes.Both - }, - }, - new ColumnTestContainer(1, ManiaAction.Key2) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) - { - RelativeSizeAxes = Axes.Both - }, - }, - } - }); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs index 62dadbc3dd..9817719c94 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { stageDefinitions = new List { - new StageDefinition { Columns = 2 } + new StageDefinition(2) }; SetContents(_ => new ManiaPlayfield(stageDefinitions)); @@ -36,8 +36,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { stageDefinitions = new List { - new StageDefinition { Columns = 2 }, - new StageDefinition { Columns = 2 } + new StageDefinition(2), + new StageDefinition(2) }; SetContents(_ => new ManiaPlayfield(stageDefinitions)); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs index f3f1b9416f..07aa0b845f 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning return new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) { - Child = new Stage(0, new StageDefinition { Columns = 4 }, ref normalAction, ref specialAction) + Child = new Stage(0, new StageDefinition(4), ref normalAction, ref specialAction) }; }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs index 687b3a747d..0744d7e2e7 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Skinning; @@ -16,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: new StageDefinition { Columns = 4 }), + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { Anchor = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs index 6cbc172755..979c90c802 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Tests.Skinning @@ -15,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: new StageDefinition { Columns = 4 }), _ => null) + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground), _ => null) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs index 21ec85bbe6..3abeb8a5f6 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | - | // | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); @@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | // | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); @@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | - | - | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); beatmap.HitObjects.Add(new Note { StartTime = 1000, Column = 1 }); @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | * | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000, Column = 1 }); @@ -115,7 +115,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | - | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); beatmap.HitObjects.Add(new Note { StartTime = 2000, Column = 1 }); @@ -142,7 +142,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new HoldNote { StartTime = 2000, Duration = 2000, Column = 1 }); @@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new Note { StartTime = 3000, Column = 1 }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs index 2922d18713..83491b6fe9 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; @@ -28,6 +29,9 @@ namespace osu.Game.Rulesets.Mania.Tests [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); + [Cached] + private readonly StageDefinition stage = new StageDefinition(1); + private readonly List columns = new List(); public TestSceneColumn() @@ -84,12 +88,12 @@ namespace osu.Game.Rulesets.Mania.Tests private Drawable createColumn(ScrollingDirection direction, ManiaAction action, int index) { - var column = new Column(index) + var column = new Column(index, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Height = 0.85f, - AccentColour = Color4.OrangeRed, + AccentColour = { Value = Color4.OrangeRed }, Action = { Value = action }, }; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs index 223f8dae44..d273f5cb35 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs @@ -4,11 +4,13 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; @@ -24,6 +26,9 @@ namespace osu.Game.Rulesets.Mania.Tests private Column column; + [Cached] + private readonly StageDefinition stage = new StageDefinition(1); + [SetUp] public void SetUp() => Schedule(() => { @@ -35,11 +40,11 @@ namespace osu.Game.Rulesets.Mania.Tests RelativeSizeAxes = Axes.Y, TimeRange = 2000, Clock = new FramedClock(clock), - Child = column = new Column(0) + Child = column = new Column(0, false) { Action = { Value = ManiaAction.Key1 }, Height = 0.85f, - AccentColour = Color4.Gray + AccentColour = { Value = Color4.Gray }, }, }; }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs index a563dc3106..1f139b5b78 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs @@ -141,7 +141,7 @@ namespace osu.Game.Rulesets.Mania.Tests { AddStep("load player", () => { - Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition(4)) { HitObjects = hitObjects, BeatmapInfo = diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs index cf8947c1ed..6387dac957 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs @@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Mania.Tests { var specialAction = ManiaAction.Special1; - var stage = new Stage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); + var stage = new Stage(0, new StageDefinition(2), ref action, ref specialAction); stages.Add(stage); return new ScrollingTestContainer(direction) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs index e84d02775a..9f2e3d2502 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs @@ -85,7 +85,7 @@ namespace osu.Game.Rulesets.Mania.Tests { const double beat_length = 500; - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }) + var beatmap = new ManiaBeatmap(new StageDefinition(1)) { HitObjects = { diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index d3b4b378c0..0d7b03d830 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -1,11 +1,9 @@  - - + - - + WinExe diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs b/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs deleted file mode 100644 index 0114987e3c..0000000000 --- a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs +++ /dev/null @@ -1,14 +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 - -namespace osu.Game.Rulesets.Mania.Beatmaps -{ - public enum ColumnType - { - Even, - Odd, - Special - } -} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index 4879ce6748..b5655a4579 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; @@ -60,5 +61,18 @@ namespace osu.Game.Rulesets.Mania.Beatmaps }, }; } + + public StageDefinition GetStageForColumnIndex(int column) + { + foreach (var stage in Stages) + { + if (column < stage.Columns) + return stage; + + column -= stage.Columns; + } + + throw new ArgumentOutOfRangeException(nameof(column), "Provided index exceeds all available stages"); + } } } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 90cd7f57b5..632b7cdcc7 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -93,10 +93,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps protected override Beatmap CreateBeatmap() { - beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }, originalTargetColumns); + beatmap = new ManiaBeatmap(new StageDefinition(TargetColumns), originalTargetColumns); if (Dual) - beatmap.Stages.Add(new StageDefinition { Columns = TargetColumns }); + beatmap.Stages.Add(new StageDefinition(TargetColumns)); return beatmap; } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs index 54e2d4686f..898b558eb3 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs @@ -11,32 +11,26 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// /// Defines properties for each stage in a . /// - public struct StageDefinition + public class StageDefinition { /// /// The number of s which this stage contains. /// - public int Columns; + public readonly int Columns; + + public StageDefinition(int columns) + { + if (columns < 1) + throw new ArgumentException("Column count must be above zero.", nameof(columns)); + + Columns = columns; + } /// /// Whether the column index is a special column for this stage. /// /// The 0-based column index. /// Whether the column is a special column. - public readonly bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; - - /// - /// Get the type of column given a column index. - /// - /// The 0-based column index. - /// The type of the column. - public readonly ColumnType GetTypeOfColumn(int column) - { - if (IsSpecialColumn(column)) - return ColumnType.Special; - - int distanceToEdge = Math.Min(column, (Columns - 1) - column); - return distanceToEdge % 2 == 0 ? ColumnType.Odd : ColumnType.Even; - } + public bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; } } diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 35ad21442e..d367c82ed8 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.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; using osu.Framework.Configuration.Tracking; using osu.Game.Configuration; @@ -13,7 +11,7 @@ namespace osu.Game.Rulesets.Mania.Configuration { public class ManiaRulesetConfigManager : RulesetConfigManager { - public ManiaRulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null) + public ManiaRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null) : base(settings, ruleset, variant) { } @@ -33,7 +31,7 @@ namespace osu.Game.Rulesets.Mania.Configuration scrollTime => new SettingDescription( rawValue: scrollTime, name: "Scroll Speed", - value: $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)} ({scrollTime}ms)" + value: $"{scrollTime}ms (speed {(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)})" ) ) }; diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 178094476f..63e61f17e3 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -31,6 +31,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty private readonly bool isForCurrentRuleset; private readonly double originalOverallDifficulty; + public override int Version => 20220902; + public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index a925e7c0ac..440dec82af 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - scoreAccuracy = customAccuracy; + scoreAccuracy = calculateCustomAccuracy(); // Arbitrary initial value for scaling pp in order to standardize distributions across game modes. // The specific number has no intrinsic meaning and can be adjusted as needed. @@ -73,6 +73,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty /// /// Accuracy used to weight judgements independently from the score's actual accuracy. /// - private double customAccuracy => (countPerfect * 320 + countGreat * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 320); + private double calculateCustomAccuracy() + { + if (totalHits == 0) + return 0; + + return (countPerfect * 320 + countGreat * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 320); + } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index c2e532430c..2c7c84de97 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -21,7 +21,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 1; - private readonly double[] holdEndTimes; + private readonly double[] startTimes; + private readonly double[] endTimes; private readonly double[] individualStrains; private double individualStrain; @@ -30,7 +31,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills public Strain(Mod[] mods, int totalColumns) : base(mods) { - holdEndTimes = new double[totalColumns]; + startTimes = new double[totalColumns]; + endTimes = new double[totalColumns]; individualStrains = new double[totalColumns]; overallStrain = 1; } @@ -38,32 +40,27 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { var maniaCurrent = (ManiaDifficultyHitObject)current; + double startTime = maniaCurrent.StartTime; double endTime = maniaCurrent.EndTime; int column = maniaCurrent.BaseObject.Column; - double closestEndTime = Math.Abs(endTime - maniaCurrent.LastObject.StartTime); // Lowest value we can assume with the current information - - double holdFactor = 1.0; // Factor to all additional strains in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly bool isOverlapping = false; - // Fill up the holdEndTimes array - for (int i = 0; i < holdEndTimes.Length; ++i) + double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information + double holdFactor = 1.0; // Factor to all additional strains in case something else is held + double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + + for (int i = 0; i < endTimes.Length; ++i) { // The current note is overlapped if a previous note or end is overlapping the current note body - isOverlapping |= Precision.DefinitelyBigger(holdEndTimes[i], maniaCurrent.StartTime, 1) && Precision.DefinitelyBigger(endTime, holdEndTimes[i], 1); + isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1); // We give a slight bonus to everything if something is held meanwhile - if (Precision.DefinitelyBigger(holdEndTimes[i], endTime, 1)) + if (Precision.DefinitelyBigger(endTimes[i], endTime, 1)) holdFactor = 1.25; - closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - holdEndTimes[i])); - - // Decay individual strains - individualStrains[i] = applyDecay(individualStrains[i], current.DeltaTime, individual_decay_base); + closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i])); } - holdEndTimes[column] = endTime; - // The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. // Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. // holdAddition @@ -77,12 +74,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills if (isOverlapping) holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime))); - // Increase individual strain in own column + // Decay and increase individualStrains in own column + individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); individualStrains[column] += 2.0 * holdFactor; - individualStrain = individualStrains[column]; - overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base) + (1 + holdAddition) * holdFactor; + // For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns + individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column]; + // Decay and increase overallStrain + overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base); + overallStrain += (1 + holdAddition) * holdFactor; + + // Update startTimes and endTimes arrays + startTimes[column] = startTime; + endTimes[column] = endTime; + + // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. return individualStrain + overallStrain - CurrentStrain; } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index ad75afff8e..f438d6497c 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -33,5 +33,7 @@ namespace osu.Game.Rulesets.Mania.Edit } protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + + protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs index b602a67baa..81c41ab628 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs @@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup private void updateBeatmap() { Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value; + Beatmap.SaveState(); } } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 4723416c30..6162184c9a 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -1,25 +1,18 @@ // 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.Beatmaps; -using osu.Game.Rulesets.Mania.Mods; -using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Replays; -using osu.Game.Rulesets.Replays.Types; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; @@ -30,13 +23,21 @@ using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Difficulty; using osu.Game.Rulesets.Mania.Edit; using osu.Game.Rulesets.Mania.Edit.Setup; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.Skinning.Argon; +using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Legacy; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; +using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania { @@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Mania /// public const int MAX_STAGE_KEYS = 10; - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableManiaRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableManiaRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); @@ -59,9 +60,29 @@ namespace osu.Game.Rulesets.Mania public const string SHORT_NAME = "mania"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new ManiaLegacySkinTransformer(skin, beatmap); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case TrianglesSkin: + return new ManiaTrianglesSkinTransformer(skin, beatmap); + + case ArgonSkin: + return new ManiaArgonSkinTransformer(skin, beatmap); + + case DefaultLegacySkin: + return new ManiaClassicSkinTransformer(skin, beatmap); + + case LegacySkin: + return new ManiaLegacySkinTransformer(skin, beatmap); + } + + return null; + } public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { @@ -282,7 +303,7 @@ namespace osu.Game.Rulesets.Mania public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame(); - public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new ManiaRulesetConfigManager(settings, RulesetInfo); + public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo); public override RulesetSettingsSubsection CreateSettings() => new ManiaSettingsSubsection(this); @@ -311,7 +332,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 +377,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/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 43d4aa77a2..e239068d1d 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -1,8 +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 - +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Mania LabelText = "Scrolling direction", Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, - new SettingsSlider + new SettingsSlider { LabelText = "Scroll speed", Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), @@ -47,5 +46,10 @@ namespace osu.Game.Rulesets.Mania } }; } + + private class ManiaScrollSlider : OsuSliderBar + { + public override LocalisableString TooltipText => $"{Current.Value}ms (speed {(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / Current.Value)})"; + } } } diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs index 21b362df00..f05edb4677 100644 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs @@ -3,29 +3,19 @@ #nullable disable -using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.UI; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania { public class ManiaSkinComponent : GameplaySkinComponent { - /// - /// The intended for this component. - /// May be null if the component is not a direct member of a . - /// - public readonly StageDefinition? StageDefinition; - /// /// Creates a new . /// /// The component. - /// The intended for this component. May be null if the component is not a direct member of a . - public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null) + public ManiaSkinComponent(ManiaSkinComponents component) : base(component) { - StageDefinition = stageDefinition; } protected override string RulesetPrefix => ManiaRuleset.SHORT_NAME; diff --git a/osu.Game.Rulesets.Mania/Mods/IPlayfieldTypeMod.cs b/osu.Game.Rulesets.Mania/Mods/IPlayfieldTypeMod.cs index 0bae893810..410386c9d5 100644 --- a/osu.Game.Rulesets.Mania/Mods/IPlayfieldTypeMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/IPlayfieldTypeMod.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs index 8f8b7cb091..050b302bd8 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.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; using System.Linq; using osu.Game.Beatmaps; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs index f9c51bf6a2..d444c9b634 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.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 osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs index 8d54923e7b..f0db742eac 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.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 osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs index 603d096ed7..073dda9de8 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs index 20dfc14f09..66269f5572 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -1,9 +1,8 @@ // 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.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; @@ -18,9 +17,9 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => "CS"; - public override double ScoreMultiplier => 1; + 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/ManiaModDaycore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs index b166f3ebc3..bec0a6a1d3 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs index d053e64315..0817f8f9fc 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs index 66627e6ed3..a302f95966 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs index f25a77278b..2457aa75d7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs @@ -1,8 +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 - +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mods; @@ -13,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 ff236d33bf..5c8cd6a5ae 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -1,14 +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 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 ddbd7c5d6a..c6e9c339f4 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -1,10 +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 System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods @@ -13,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/ManiaModFlashlight.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs index 58005df561..947915cdf9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs @@ -1,13 +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.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Layout; -using osu.Game.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using osuTK; @@ -19,22 +16,14 @@ namespace osu.Game.Rulesets.Mania.Mods public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModHidden) }; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 3f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = false, - Value = false - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(); public override float DefaultFlashlightSize => 50; @@ -47,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.Mods public ManiaFlashlight(ManiaModFlashlight modFlashlight) : base(modFlashlight) { - FlashlightSize = new Vector2(DrawWidth, GetSizeFor(0)); + FlashlightSize = new Vector2(DrawWidth, GetSize()); AddLayout(flashlightProperties); } @@ -65,9 +54,9 @@ namespace osu.Game.Rulesets.Mania.Mods } } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(DrawWidth, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(DrawWidth, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "RectangularFlashlight"; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs index 87c81c2866..014954dd60 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs index 380edca515..d9de06a811 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index 698555ddc4..eeb6e94fc7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -1,17 +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.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 8c4fd0a8fc..ca9bc89473 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.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; using System.Linq; using osu.Game.Beatmaps; @@ -10,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 @@ -22,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 f202b859b1..ef9154d180 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs @@ -1,12 +1,11 @@ // 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.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -22,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 9ce4fb6a48..31f52610e9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 f378ce3435..67e65b887a 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 5812df80f5..0f8148d252 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 4116ed5ceb..0f8af7940c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 9879fec686..d3a4546dce 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 646386b0d8..693182a952 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 56af9ed589..ab911292f7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 a0a7116ed7..ab401ef1d0 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 fc8ecdb9ea..b3e8a45dda 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 c495a6c82f..5972cbf0fe 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,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 6e56981fc8..f9690b4298 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -1,12 +1,11 @@ // 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.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; @@ -14,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/ManiaModMuted.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs index 076f634968..33ebcf303a 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.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.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs index 9ae664e1f6..4cc712060c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.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.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNoFail.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNoFail.cs index 487f32dc26..e8988be548 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNoFail.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNoFail.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.cs index 2789a2a06e..2e22e23dbd 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs index 5da29e5a1d..6a94e5d371 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.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; using System.Linq; using osu.Framework.Graphics; @@ -33,7 +31,7 @@ namespace osu.Game.Rulesets.Mania.Mods HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; Container hocParent = (Container)hoc.Parent; - hocParent.Remove(hoc); + hocParent.Remove(hoc, false); hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => { c.RelativeSizeAxes = Axes.Both; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index 22347d21b8..6ff070d703 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -1,11 +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 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; @@ -16,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.Mania/Mods/ManiaModSuddenDeath.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModSuddenDeath.cs index 17759d718e..ecc343ecaa 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModSuddenDeath.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModSuddenDeath.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs index 0e2d6bce21..a607ed572d 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs @@ -1,12 +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 osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -16,21 +13,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableBarLine : DrawableManiaHitObject { - /// - /// Height of major bar line triangles. - /// - private const float triangle_height = 12; - - /// - /// Offset of the major bar line triangles from the sides of the bar line. - /// - private const float triangle_offset = 9; - public DrawableBarLine(BarLine barLine) : base(barLine) { RelativeSizeAxes = Axes.X; - Height = 2f; + Height = barLine.Major ? 1.7f : 1.2f; AddInternal(new Box { @@ -38,38 +25,33 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.Both, - Colour = new Color4(255, 204, 33, 255), + Alpha = barLine.Major ? 0.5f : 0.2f }); if (barLine.Major) { - AddInternal(new EquilateralTriangle + Vector2 size = new Vector2(22, 6); + const float line_offset = 4; + + AddInternal(new Circle { - Name = "Left triangle", - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopCentre, - Size = new Vector2(triangle_height), - X = -triangle_offset, - Rotation = 90 + Name = "Left line", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + + Size = size, + X = -line_offset, }); - AddInternal(new EquilateralTriangle + AddInternal(new Circle { - Name = "Right triangle", - Anchor = Anchor.BottomRight, - Origin = Anchor.TopCentre, - Size = new Vector2(triangle_height), - X = triangle_offset, - Rotation = -90 + Name = "Right line", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreLeft, + Size = size, + X = line_offset, }); } - - if (!barLine.Major) - Alpha = 0.2f; - } - - protected override void UpdateInitialTransforms() - { } protected override void UpdateStartTimeStateTransforms() => this.FadeOut(150); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 19792086a7..48647f9f5f 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -4,12 +4,14 @@ #nullable disable using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Audio; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -38,6 +40,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private Container tailContainer; private Container tickContainer; + private PausableSkinnableSound slidingSample; + /// /// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed. /// @@ -108,6 +112,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }, tickContainer = new Container { RelativeSizeAxes = Axes.Both }, tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + slidingSample = new PausableSkinnableSound { Looping = true } }); maskedContents.AddRange(new[] @@ -118,6 +123,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + isHitting.BindValueChanged(updateSlidingSample, true); + } + protected override void OnApply() { base.OnApply(); @@ -322,5 +334,38 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables HoldStartTime = null; isHitting.Value = false; } + + protected override void LoadSamples() + { + // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. + + if (HitObject.SampleControlPoint == null) + { + throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." + + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); + } + + slidingSample.Samples = HitObject.CreateSlidingSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + } + + public override void StopAllSamples() + { + base.StopAllSamples(); + slidingSample?.Stop(); + } + + private void updateSlidingSample(ValueChangedEvent tracking) + { + if (tracking.NewValue) + slidingSample?.Play(); + else + slidingSample?.Stop(); + } + + protected override void OnFree() + { + slidingSample.Samples = null; + base.OnFree(); + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs index d374e935ec..ac646ea427 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -30,20 +30,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public bool UpdateResult() => base.UpdateResult(true); - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - // This hitobject should never expire, so this is just a safe maximum. - LifetimeEnd = LifetimeStart + 30000; - } - protected override void UpdateHitStateTransforms(ArmedState state) { // suppress the base call explicitly. // the hold note head should never change its visual state on its own due to the "freezing" mechanic // (when hit, it remains visible in place at the judgement line; when dropped, it will scroll past the line). // it will be hidden along with its parenting hold note when required. + + // Set `LifetimeEnd` explicitly to a non-`double.MaxValue` because otherwise this DHO is automatically expired. + LifetimeEnd = double.PositiveInfinity; } public override bool OnPressed(KeyBindingPressEvent e) => false; // Handled by the hold note diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index a7bdcd047e..3084f71be2 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public void UpdateResult() => base.UpdateResult(true); - protected override double MaximumJudgementOffset => base.MaximumJudgementOffset * release_window_lenience; + public override double MaximumJudgementOffset => base.MaximumJudgementOffset * release_window_lenience; protected override void CheckForResult(bool userTriggered, double timeOffset) { diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index bcc10ab7bc..73dc937a00 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -23,10 +23,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected readonly IBindable Direction = new Bindable(); - // Leaving the default (10s) makes hitobjects not appear, as this offset is used for the initial state transforms. - // Calculated as DrawableManiaRuleset.MAX_TIME_RANGE + some additional allowance for velocity < 1. - protected override double InitialLifetimeOffset => 30000; - [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } @@ -69,22 +65,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Direction.BindValueChanged(OnDirectionChanged, true); } - protected override void OnApply() - { - base.OnApply(); - - if (ParentHitObject != null) - AccentColour.BindTo(ParentHitObject.AccentColour); - } - - protected override void OnFree() - { - base.OnFree(); - - if (ParentHitObject != null) - AccentColour.UnbindFrom(ParentHitObject.AccentColour); - } - protected virtual void OnDirectionChanged(ValueChangedEvent e) { Anchor = Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 0efaeac026..ebff5cf4e9 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -13,12 +13,14 @@ namespace osu.Game.Rulesets.Mania.Objects { public abstract class ManiaHitObject : HitObject, IHasColumn, IHasXPosition { - public readonly Bindable ColumnBindable = new Bindable(); + private HitObjectProperty column; + + public Bindable ColumnBindable => column.Bindable; public virtual int Column { - get => ColumnBindable.Value; - set => ColumnBindable.Value = value; + get => column.Value; + set => column.Value = value; } protected override HitWindows CreateHitWindows() => new ManiaHitWindows(); diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs similarity index 50% rename from osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs rename to osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs index 5bd2d3ab48..598a765d3c 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs @@ -1,8 +1,6 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -12,26 +10,38 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK.Graphics; -namespace osu.Game.Rulesets.Mania.UI.Components +namespace osu.Game.Rulesets.Mania.Skinning.Argon { - public class ColumnBackground : CompositeDrawable, IKeyBindingHandler, IHasAccentColour + public class ArgonColumnBackground : CompositeDrawable, IKeyBindingHandler { - private readonly IBindable action = new Bindable(); - - private Box background; - private Box backgroundOverlay; - private readonly IBindable direction = new Bindable(); - [BackgroundDependencyLoader] - private void load(IBindable action, IScrollingInfo scrollingInfo) - { - this.action.BindTo(action); + private Color4 brightColour; + private Color4 dimColour; + private Box background = null!; + private Box backgroundOverlay = null!; + + [Resolved] + private Column column { get; set; } = null!; + + private Bindable accentColour = null!; + + public ArgonColumnBackground() + { + RelativeSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { InternalChildren = new[] { background = new Box @@ -49,61 +59,42 @@ namespace osu.Game.Rulesets.Mania.UI.Components } }; - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => { - backgroundOverlay.Anchor = backgroundOverlay.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - updateColours(); + background.Colour = colour.NewValue.Darken(3).Opacity(0.8f); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); }, true); + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); } - protected override void LoadComplete() + private void onDirectionChanged(ValueChangedEvent direction) { - base.LoadComplete(); - updateColours(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set + if (direction.NewValue == ScrollingDirection.Up) { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.TopLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(brightColour, dimColour); + } + else + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.BottomLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(dimColour, brightColour); } - } - - private void updateColours() - { - if (!IsLoaded) - return; - - background.Colour = AccentColour.Darken(5); - - var brightPoint = AccentColour.Opacity(0.6f); - var dimPoint = AccentColour.Opacity(0); - - backgroundOverlay.Colour = ColourInfo.GradientVertical( - direction.Value == ScrollingDirection.Up ? brightPoint : dimPoint, - direction.Value == ScrollingDirection.Up ? dimPoint : brightPoint); } public bool OnPressed(KeyBindingPressEvent e) { - if (e.Action == action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); return false; } public void OnReleased(KeyBindingReleaseEvent e) { - if (e.Action == action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs new file mode 100644 index 0000000000..af179d5580 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs @@ -0,0 +1,97 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonHitExplosion : CompositeDrawable, IHitExplosion + { + public override bool RemoveWhenNotAlive => true; + + [Resolved] + private Column column { get; set; } = null!; + + private readonly IBindable direction = new Bindable(); + + private Container largeFaint = null!; + + private Bindable accentColour = null!; + + public ArgonHitExplosion() + { + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new Drawable[] + { + largeFaint = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Blending = BlendingParameters.Additive, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.Colour = Interpolation.ValueAt(0.8f, colour.NewValue, Color4.White, 0, 1); + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour.NewValue, + Roundness = 40, + Radius = 60, + }; + }, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + Anchor = Anchor.TopCentre; + Y = ArgonNotePiece.NOTE_HEIGHT / 2; + } + else + { + Anchor = Anchor.BottomCentre; + Y = -ArgonNotePiece.NOTE_HEIGHT / 2; + } + } + + public void Animate(JudgementResult result) + { + this.FadeOutFromOne(PoolableHitExplosion.DURATION, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs new file mode 100644 index 0000000000..9e449623d5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs @@ -0,0 +1,47 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonHitTarget : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + + InternalChildren = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.3f, + Blending = BlendingParameters.Additive, + Colour = Color4.White + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Anchor = Origin = direction.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs new file mode 100644 index 0000000000..757190c4ae --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs @@ -0,0 +1,97 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + /// + /// Represents length-wise portion of a hold note. + /// + public class ArgonHoldBodyPiece : CompositeDrawable, IHoldNoteBody + { + protected readonly Bindable AccentColour = new Bindable(); + protected readonly IBindable IsHitting = new Bindable(); + + private Drawable background = null!; + private Box foreground = null!; + + public ArgonHoldBodyPiece() + { + RelativeSizeAxes = Axes.Both; + + // Without this, the width of the body will be slightly larger than the head/tail. + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + InternalChildren = new[] + { + background = new Box { RelativeSizeAxes = Axes.Both }, + foreground = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }; + + if (drawableObject != null) + { + var holdNote = (DrawableHoldNote)drawableObject; + + AccentColour.BindTo(holdNote.AccentColour); + IsHitting.BindTo(holdNote.IsHitting); + } + + AccentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(1.2f); + foreground.Colour = colour.NewValue.Opacity(0.2f); + }, true); + + IsHitting.BindValueChanged(hitting => + { + const float animation_length = 50; + + foreground.ClearTransforms(); + + if (hitting.NewValue) + { + // wait for the next sync point + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + + using (foreground.BeginDelayedSequence(synchronisedOffset)) + { + foreground.FadeTo(1, animation_length).Then() + .FadeTo(0.5f, animation_length) + .Loop(); + } + } + else + { + foreground.FadeOut(animation_length); + } + }); + } + + public void Recycle() + { + foreground.ClearTransforms(); + foreground.Alpha = 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs new file mode 100644 index 0000000000..e1068c6cd8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs @@ -0,0 +1,91 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + internal class ArgonHoldNoteTailPiece : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + private readonly Box shadow; + + public ArgonHoldNoteTailPiece() + { + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + shadow = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.82f, + Masking = true, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Circle + { + RelativeSizeAxes = Axes.X, + Height = ArgonNotePiece.CORNER_RADIUS * 2, + }, + }; + } + + [BackgroundDependencyLoader(true)] + private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue, + accent.NewValue.Darken(0.1f) + ); + + shadow.Colour = accent.NewValue.Darken(0.5f); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 0000000000..e7dfec256d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,193 @@ +// 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.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonJudgementPiece : CompositeDrawable, IAnimatableJudgement + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } = null!; + + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + { + Result = result; + Origin = Anchor.Centre; + Y = 160; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + JudgementText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = Result.GetDescription().ToUpperInvariant(), + Colour = colours.ForHitResult(Result), + Blending = BlendingParameters.Additive, + Spacing = new Vector2(10, 0), + Font = OsuFont.Default.With(size: 28, weight: FontWeight.Regular), + }, + }; + + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + }); + } + } + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + switch (Result) + { + default: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.4f), 1800, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + break; + } + + this.FadeOutFromOne(800); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private class RingExplosion : CompositeDrawable + { + private readonly float travel = 52; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.3f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs new file mode 100644 index 0000000000..7670c9bdf2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs @@ -0,0 +1,272 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonKeyArea : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer = null!; + private Drawable background = null!; + + private Circle hitTargetLine = null!; + + private Container bottomIcon = null!; + private CircularContainer topIcon = null!; + + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + public ArgonKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + const float icon_circle_size = 8; + const float icon_spacing = 7; + const float icon_vertical_offset = -30; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + // Ensure the area is tall enough to put the target line in the correct location. + // This is to also allow the main background component to overlap the target line + // and avoid an inner corner radius being shown below the target line. + Height = Stage.HIT_TARGET_POSITION + ArgonNotePiece.CORNER_RADIUS * 2, + Children = new[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Child = background = new Box + { + Name = "Key gradient", + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + hitTargetLine = new Circle + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = OsuColour.Gray(196 / 255f), + Height = ArgonNotePiece.CORNER_RADIUS * 2, + Masking = true, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Container + { + Name = "Icons", + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + bottomIcon = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Y = icon_vertical_offset, + Children = new[] + { + new Circle + { + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = -icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + } + }, + topIcon = new CircularContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -icon_vertical_offset, + Size = new Vector2(22, 14), + Masking = true, + BorderThickness = 4, + BorderColour = Color4.White, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + } + } + }, + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.2f); + bottomIcon.Colour = colour.NewValue; + }, + true); + + // Yes, proxy everything. + column.TopLevelContainer.Add(CreateProxy()); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + switch (direction.NewValue) + { + case ScrollingDirection.Up: + directionContainer.Scale = new Vector2(1, -1); + directionContainer.Anchor = Anchor.TopLeft; + directionContainer.Origin = Anchor.BottomLeft; + break; + + case ScrollingDirection.Down: + directionContainer.Scale = new Vector2(1, 1); + directionContainer.Anchor = Anchor.BottomLeft; + directionContainer.Origin = Anchor.BottomLeft; + break; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != column.Action.Value) return false; + + const double lighting_fade_in_duration = 70; + Color4 lightingColour = getLightingColour(); + + background + .FlashColour(accentColour.Value.Lighten(0.8f), 200, Easing.OutQuint) + .FadeTo(1, lighting_fade_in_duration, Easing.OutQuint) + .Then() + .FadeTo(0.8f, 500); + + hitTargetLine.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.4f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + topIcon.ScaleTo(0.9f, lighting_fade_in_duration, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.1f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + bottomIcon.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.2f), + Radius = 60, + }, lighting_fade_in_duration, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action != column.Action.Value) return; + + const double lighting_fade_out_duration = 800; + + Color4 lightingColour = getLightingColour().Opacity(0); + + // background fades out faster than lighting elements to give better definition to the player. + background.FadeTo(0.3f, 50, Easing.OutQuint) + .Then() + .FadeOut(lighting_fade_out_duration, Easing.OutQuint); + + topIcon.ScaleTo(1f, 200, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 20, + }, lighting_fade_out_duration, Easing.OutQuint); + + hitTargetLine.FadeColour(OsuColour.Gray(196 / 255f), lighting_fade_out_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 25, + }, lighting_fade_out_duration, Easing.OutQuint); + + bottomIcon.FadeColour(accentColour.Value, lighting_fade_out_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 30, + }, lighting_fade_out_duration, Easing.OutQuint); + } + } + + private Color4 getLightingColour() => Interpolation.ValueAt(0.2f, accentColour.Value, Color4.White, 0, 1); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs new file mode 100644 index 0000000000..454a6b012b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs @@ -0,0 +1,110 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + internal class ArgonNotePiece : CompositeDrawable + { + public const float NOTE_HEIGHT = 42; + + public const float CORNER_RADIUS = 3.4f; + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + private readonly Box shadow; + + public ArgonNotePiece() + { + RelativeSizeAxes = Axes.X; + Height = NOTE_HEIGHT; + + CornerRadius = CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + shadow = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = 0.82f, + Masking = true, + CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Circle + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = CORNER_RADIUS * 2, + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 4, + Icon = FontAwesome.Solid.AngleDown, + Size = new Vector2(20), + Scale = new Vector2(1, 0.7f) + } + }; + } + + [BackgroundDependencyLoader(true)] + private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue.Lighten(0.1f), + accent.NewValue + ); + + shadow.Colour = accent.NewValue.Darken(0.5f); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs new file mode 100644 index 0000000000..1881695b14 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs @@ -0,0 +1,16 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonStageBackground : CompositeDrawable + { + public ArgonStageBackground() + { + RelativeSizeAxes = Axes.Both; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs new file mode 100644 index 0000000000..ae313e0b91 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -0,0 +1,141 @@ +// 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.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ManiaArgonSkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + + public ManiaArgonSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + } + + public override Drawable? GetDrawableComponent(ISkinComponent component) + { + switch (component) + { + case GameplaySkinComponent resultComponent: + return new ArgonJudgementPiece(resultComponent.Component); + + case ManiaSkinComponent maniaComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (maniaComponent.Component) + { + case ManiaSkinComponents.StageBackground: + return new ArgonStageBackground(); + + case ManiaSkinComponents.ColumnBackground: + return new ArgonColumnBackground(); + + case ManiaSkinComponents.HoldNoteBody: + return new ArgonHoldBodyPiece(); + + case ManiaSkinComponents.HoldNoteTail: + return new ArgonHoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteHead: + case ManiaSkinComponents.Note: + return new ArgonNotePiece(); + + case ManiaSkinComponents.HitTarget: + return new ArgonHitTarget(); + + case ManiaSkinComponents.KeyArea: + return new ArgonKeyArea(); + + case ManiaSkinComponents.HitExplosion: + return new ArgonHitExplosion(); + } + + break; + } + + return base.GetDrawableComponent(component); + } + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + int column = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(column); + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnSpacing: + return SkinUtils.As(new Bindable(2)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: + case LegacyManiaSkinConfigurationLookups.StagePaddingTop: + return SkinUtils.As(new Bindable(30)); + + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + return SkinUtils.As(new Bindable( + stage.IsSpecialColumn(column) ? 120 : 60 + )); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + + Color4 colour; + + const int total_colours = 7; + + if (stage.IsSpecialColumn(column)) + colour = new Color4(159, 101, 255, 255); + else + { + switch (column % total_colours) + { + case 0: + colour = new Color4(240, 216, 0, 255); + break; + + case 1: + colour = new Color4(240, 101, 0, 255); + break; + + case 2: + colour = new Color4(240, 0, 130, 255); + break; + + case 3: + colour = new Color4(192, 0, 240, 255); + break; + + case 4: + colour = new Color4(0, 96, 240, 255); + break; + + case 5: + colour = new Color4(0, 226, 240, 255); + break; + + case 6: + colour = new Color4(0, 240, 96, 255); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + return SkinUtils.As(new Bindable(colour)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs new file mode 100644 index 0000000000..eb51179cea --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs @@ -0,0 +1,49 @@ +// 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.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Default +{ + public class ManiaTrianglesSkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + + public ManiaTrianglesSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + } + + private readonly Color4 colourEven = new Color4(6, 84, 0, 255); + private readonly Color4 colourOdd = new Color4(94, 0, 57, 255); + private readonly Color4 colourSpecial = new Color4(0, 48, 63, 255); + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + int column = maniaLookup.ColumnIndex ?? 0; + + var stage = beatmap.GetStageForColumnIndex(column); + + if (stage.IsSpecialColumn(column)) + return SkinUtils.As(new Bindable(colourSpecial)); + + int distanceToEdge = Math.Min(column, (stage.Columns - 1) - column); + return SkinUtils.As(new Bindable(distanceToEdge % 2 == 0 ? colourOdd : colourEven)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs index 4e6493ba18..49ba503cb5 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs @@ -9,7 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; @@ -155,7 +155,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy else { lightContainer.FadeOut(120) - .OnComplete(d => Column.TopLevelContainer.Remove(d)); + .OnComplete(d => Column.TopLevelContainer.Remove(d, false)); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs index ab953ccfb9..e227c80845 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -20,6 +21,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy [Resolved] protected Column Column { get; private set; } + [Resolved] + private StageDefinition stage { get; set; } + /// /// The column type identifier to use for texture lookups, in the case of no user-provided configuration. /// @@ -28,19 +32,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy [BackgroundDependencyLoader] private void load() { - switch (Column.ColumnType) + if (Column.IsSpecial) + FallbackColumnIndex = "S"; + else { - case ColumnType.Special: - FallbackColumnIndex = "S"; - break; - - case ColumnType.Odd: - FallbackColumnIndex = "1"; - break; - - case ColumnType.Even: - FallbackColumnIndex = "2"; - break; + int distanceToEdge = Math.Min(Column.Index, (stage.Columns - 1) - Column.Index); + FallbackColumnIndex = distanceToEdge % 2 == 0 ? "1" : "2"; } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs index f185c8b99d..41e149ea2f 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs @@ -9,7 +9,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.UI.Scrolling; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs index 740ccbfe27..d039551cd7 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs @@ -18,20 +18,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class LegacyStageBackground : CompositeDrawable { - private readonly StageDefinition stageDefinition; - private Drawable leftSprite; private Drawable rightSprite; private ColumnFlow columnBackgrounds; - public LegacyStageBackground(StageDefinition stageDefinition) + public LegacyStageBackground() { - this.stageDefinition = stageDefinition; RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(ISkinSource skin, StageDefinition stageDefinition) { string leftImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LeftStageImage)?.Value ?? "mania-stage-left"; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs new file mode 100644 index 0000000000..e57927897c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs @@ -0,0 +1,38 @@ +// 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.Game.Beatmaps; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Legacy +{ + public class ManiaClassicSkinTransformer : ManiaLegacySkinTransformer + { + public ManiaClassicSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin, beatmap) + { + } + + public override IBindable GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + var baseLookup = base.GetConfig(lookup); + + if (baseLookup != null) + return baseLookup; + + // default provisioning. + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + return SkinUtils.As(new Bindable(Color4.Black)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index dd5baa8150..a07dbea368 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class ManiaLegacySkinTransformer : LegacySkinTransformer { - private readonly ManiaBeatmap beatmap; + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasKeyTexture.Value; /// /// Mapping of to their corresponding @@ -60,6 +59,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy /// private readonly Lazy hasKeyTexture; + private readonly ManiaBeatmap beatmap; + public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap) : base(skin) { @@ -113,8 +114,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return new LegacyHitExplosion(); case ManiaSkinComponents.StageBackground: - Debug.Assert(maniaComponent.StageDefinition != null); - return new LegacyStageBackground(maniaComponent.StageDefinition.Value); + return new LegacyStageBackground(); case ManiaSkinComponents.StageForeground: return new LegacyStageForeground(); @@ -151,7 +151,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy public override IBindable GetConfig(TLookup lookup) { if (lookup is ManiaSkinConfigurationLookup maniaLookup) - return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn)); + { + return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.ColumnIndex)); + } return base.GetConfig(lookup); } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs index 4d0c321116..e22bf63049 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs @@ -15,9 +15,9 @@ namespace osu.Game.Rulesets.Mania.Skinning /// /// The skin from which configuration is retrieved. /// The value to retrieve. - /// If not null, denotes the index of the column to which the entry applies. - public static IBindable GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? index = null) + /// If not null, denotes the index of the column to which the entry applies. + public static IBindable GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) => skin.GetConfig( - new ManiaSkinConfigurationLookup(lookup, index)); + new ManiaSkinConfigurationLookup(lookup, columnIndex)); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs index e9005a3da0..59188f02f9 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs @@ -16,20 +16,21 @@ namespace osu.Game.Rulesets.Mania.Skinning public readonly LegacyManiaSkinConfigurationLookups Lookup; /// - /// The intended index for the configuration. + /// The column which is being looked up. /// May be null if the configuration does not apply to a . + /// Note that this is the absolute index across all stages. /// - public readonly int? TargetColumn; + public readonly int? ColumnIndex; /// /// Creates a new . /// /// The lookup value. - /// The intended index for the configuration. May be null if the configuration does not apply to a . - public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + /// The intended index for the configuration. May be null if the configuration does not apply to a . + public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) { Lookup = lookup; - TargetColumn = targetColumn; + ColumnIndex = columnIndex; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index deb1b155b5..3d46bdaa7b 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -3,30 +3,30 @@ #nullable disable -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Platform; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; -using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.UI; +using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { [Cached] - public class Column : ScrollingPlayfield, IKeyBindingHandler, IHasAccentColour + public class Column : ScrollingPlayfield, IKeyBindingHandler { public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; @@ -39,23 +39,46 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable Action = new Bindable(); public readonly ColumnHitObjectArea HitObjectArea; - internal readonly Container TopLevelContainer; - private readonly DrawablePool hitExplosionPool; + internal readonly Container TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }; + private DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; public Container UnderlayElements => HitObjectArea.UnderlayElements; - private readonly GameplaySampleTriggerSource sampleTriggerSource; + private GameplaySampleTriggerSource sampleTriggerSource; - public Column(int index) + /// + /// Whether this is a special (ie. scratch) column. + /// + public readonly bool IsSpecial; + + public readonly Bindable AccentColour = new Bindable(Color4.Black); + + public Column(int index, bool isSpecial) { Index = index; + IsSpecial = isSpecial; RelativeSizeAxes = Axes.Y; Width = COLUMN_WIDTH; + hitPolicy = new OrderedHitPolicy(HitObjectContainer); + HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }; + } + + [Resolved] + private ISkinSource skin { get; set; } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + SkinnableDrawable keyArea; + + skin.SourceChanged += onSourceChanged; + onSourceChanged(); + Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, }; InternalChildren = new[] @@ -64,17 +87,18 @@ namespace osu.Game.Rulesets.Mania.UI sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), - HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) + HitObjectArea, + keyArea = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, }, background, - TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }, + TopLevelContainer, new ColumnTouchInputArea(this) }; - hitPolicy = new OrderedHitPolicy(HitObjectContainer); + applyGameWideClock(background); + applyGameWideClock(keyArea); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); @@ -83,20 +107,38 @@ namespace osu.Game.Rulesets.Mania.UI RegisterPool(10, 50); RegisterPool(10, 50); RegisterPool(50, 250); + + // Some elements don't handle rewind correctly and fixing them is non-trivial. + // In the future we need a better solution to this, but as a temporary work-around, give these components the game-wide + // clock so they don't need to worry about rewind. + // This only works because they handle OnPressed/OnReleased which results in a correct state while rewinding. + // + // This is kinda dodgy (and will cause weirdness when pausing gameplay) but is better than completely broken rewind. + void applyGameWideClock(Drawable drawable) + { + drawable.Clock = host.UpdateThread.Clock; + drawable.ProcessCustomClock = false; + } + } + + private void onSourceChanged() + { + AccentColour.Value = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, Index)?.Value ?? Color4.Black; } protected override void LoadComplete() { base.LoadComplete(); - NewResult += OnNewResult; } - public ColumnType ColumnType { get; set; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); - public bool IsSpecial => ColumnType == ColumnType.Special; - - public Color4 AccentColour { get; set; } + if (skin != null) + skin.SourceChanged -= onSourceChanged; + } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -111,7 +153,7 @@ namespace osu.Game.Rulesets.Mania.UI DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject; - maniaObject.AccentColour.Value = AccentColour; + maniaObject.AccentColour.BindTo(AccentColour); maniaObject.CheckHittable = hitPolicy.IsHittable; } diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index 871ec9f1a3..9b3f6d7033 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -36,6 +36,8 @@ namespace osu.Game.Rulesets.Mania.UI AutoSizeAxes = Axes.X; + Masking = true; + InternalChild = columns = new FillFlowContainer { RelativeSizeAxes = Axes.Y, diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs index 39d17db6be..3680e7ea0a 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Mania.UI.Components [Resolved] private Column column { get; set; } + private Bindable accentColour; + public DefaultColumnBackground() { RelativeSizeAxes = Axes.Both; @@ -55,9 +57,13 @@ namespace osu.Game.Rulesets.Mania.UI.Components } }; - background.Colour = column.AccentColour.Darken(5); - brightColour = column.AccentColour.Opacity(0.6f); - dimColour = column.AccentColour.Opacity(0); + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(5); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); + }, true); direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs index 53fa86125f..97aa897782 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Mania.UI.Components private Container hitTargetLine; private Drawable hitTargetBar; + private Bindable accentColour; + [Resolved] private Column column { get; set; } @@ -54,12 +56,16 @@ namespace osu.Game.Rulesets.Mania.UI.Components }, }; - hitTargetLine.EdgeEffect = new EdgeEffectParameters + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = column.AccentColour.Opacity(0.5f), - }; + hitTargetLine.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = colour.NewValue.Opacity(0.5f), + }; + }, true); direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs index 5a0fab2ff4..600c9feb73 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Mania.UI.Components private Container keyIcon; private Drawable gradient; + private Bindable accentColour; + [Resolved] private Column column { get; set; } @@ -75,15 +77,19 @@ namespace osu.Game.Rulesets.Mania.UI.Components } }; - keyIcon.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = column.AccentColour.Opacity(0.5f), - }; - direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + keyIcon.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = colour.NewValue.Opacity(0.5f), + }; + }, true); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs index e83cd10d2d..59716ee3e2 100644 --- a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs @@ -32,6 +32,10 @@ namespace osu.Game.Rulesets.Mania.UI private CircularContainer largeFaint; private CircularContainer mainGlow1; + private CircularContainer mainGlow2; + private CircularContainer mainGlow3; + + private Bindable accentColour; public DefaultHitExplosion() { @@ -48,8 +52,6 @@ namespace osu.Game.Rulesets.Mania.UI const float roundness = 80; const float initial_height = 10; - var colour = Interpolation.ValueAt(0.4f, column.AccentColour, Color4.White, 0, 1); - InternalChildren = new Drawable[] { largeFaint = new CircularContainer @@ -61,13 +63,6 @@ namespace osu.Game.Rulesets.Mania.UI // we want our size to be very small so the glow dominates it. Size = new Vector2(default_large_faint_size), Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, column.AccentColour, Color4.White, 0, 1).Opacity(0.3f), - Roundness = 160, - Radius = 200, - }, }, mainGlow1 = new CircularContainer { @@ -76,15 +71,8 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Both, Masking = true, Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.6f, column.AccentColour, Color4.White, 0, 1), - Roundness = 20, - Radius = 50, - }, }, - new CircularContainer + mainGlow2 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -93,15 +81,8 @@ namespace osu.Game.Rulesets.Mania.UI Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, Rotation = RNG.NextSingle(-angle_variance, angle_variance), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, }, - new CircularContainer + mainGlow3 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -110,18 +91,44 @@ namespace osu.Game.Rulesets.Mania.UI Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, Rotation = RNG.NextSingle(-angle_variance, angle_variance), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, } }; direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, colour.NewValue, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }; + mainGlow1.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, colour.NewValue, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }; + mainGlow2.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, colour.NewValue, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + mainGlow3.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, colour.NewValue, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + }, true); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs index 28509d1f4e..a7b94f9f22 100644 --- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -42,6 +42,8 @@ namespace osu.Game.Rulesets.Mania.UI { base.PrepareForUse(); + LifetimeStart = Time.Current; + (skinnableExplosion?.Drawable as IHitExplosion)?.Animate(Result); this.Delay(DURATION).Then().Expire(); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index c578bbb703..1273cb3d32 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -12,6 +13,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -19,7 +21,6 @@ using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { @@ -28,6 +29,9 @@ namespace osu.Game.Rulesets.Mania.UI /// public class Stage : ScrollingPlayfield { + [Cached] + public readonly StageDefinition Definition; + public const float COLUMN_SPACING = 1; public const float HIT_TARGET_POSITION = 110; @@ -40,13 +44,6 @@ namespace osu.Game.Rulesets.Mania.UI private readonly Drawable barLineContainer; - private readonly Dictionary columnColours = new Dictionary - { - { ColumnType.Even, new Color4(6, 84, 0, 255) }, - { ColumnType.Odd, new Color4(94, 0, 57, 255) }, - { ColumnType.Special, new Color4(0, 48, 63, 255) } - }; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Columns.Any(c => c.ReceivePositionalInputAt(screenSpacePos)); private readonly int firstColumnIndex; @@ -54,6 +51,7 @@ namespace osu.Game.Rulesets.Mania.UI public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) { this.firstColumnIndex = firstColumnIndex; + Definition = definition; Name = "Stage"; @@ -75,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.UI AutoSizeAxes = Axes.X, Children = new Drawable[] { - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: definition), _ => new DefaultStageBackground()) + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { RelativeSizeAxes = Axes.Both }, @@ -100,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y, } }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: definition), _ => null) + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground), _ => null) { RelativeSizeAxes = Axes.Both }, @@ -118,15 +116,13 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < definition.Columns; i++) { - var columnType = definition.GetTypeOfColumn(i); + bool isSpecial = definition.IsSpecialColumn(i); - var column = new Column(firstColumnIndex + i) + var column = new Column(firstColumnIndex + i, isSpecial) { RelativeSizeAxes = Axes.Both, Width = 1, - ColumnType = columnType, - AccentColour = columnColours[columnType], - Action = { Value = columnType == ColumnType.Special ? specialColumnStartAction++ : normalColumnStartAction++ } + Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ } }; topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); @@ -135,6 +131,37 @@ namespace osu.Game.Rulesets.Mania.UI } } + private ISkinSource currentSkin; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + currentSkin = skin; + + skin.SourceChanged += onSkinChanged; + onSkinChanged(); + } + + private void onSkinChanged() + { + float paddingTop = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingTop))?.Value ?? 0; + float paddingBottom = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingBottom))?.Value ?? 0; + + Padding = new MarginPadding + { + Top = paddingTop, + Bottom = paddingBottom, + }; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (currentSkin != null) + currentSkin.SourceChanged -= onSkinChanged; + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs new file mode 100644 index 0000000000..5c5384e0b7 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs @@ -0,0 +1,299 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneObjectMerging : TestSceneOsuEditor + { + private OsuSelectionHandler selectionHandler => Editor.ChildrenOfType().First(); + + [Test] + public void TestSimpleMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + + AddStep("select first two circles", () => + { + circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle); + circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1); + EditorClock.Seek(circle1.StartTime); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + moveMouseToHitObject(1); + AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); + + mergeSelection(); + + AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor( + (pos: circle1.Position, pathType: PathType.Linear), + (pos: circle2.Position, pathType: null))); + + AddStep("undo", () => Editor.Undo()); + AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && objectsRestored(circle1, circle2)); + } + + [Test] + public void TestMergeCircleSlider() + { + HitCircle? circle1 = null; + Slider? slider = null; + HitCircle? circle2 = null; + + AddStep("select a circle, slider, circle", () => + { + circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle); + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > circle1.StartTime); + circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h.StartTime > slider.StartTime); + EditorClock.Seek(circle1.StartTime); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(slider); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + mergeSelection(); + + AddAssert("slider created", () => + { + if (circle1 is null || circle2 is null || slider is null) + return false; + + var controlPoints = slider.Path.ControlPoints; + (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints.Count + 2]; + args[0] = (circle1.Position, PathType.Linear); + + for (int i = 0; i < controlPoints.Count; i++) + { + args[i + 1] = (controlPoints[i].Position + slider.Position, i == controlPoints.Count - 1 ? PathType.Linear : controlPoints[i].Type); + } + + args[^1] = (circle2.Position, null); + return sliderCreatedFor(args); + }); + + AddAssert("samples exist", sliderSampleExist); + + AddStep("undo", () => Editor.Undo()); + AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2)); + } + + [Test] + public void TestMergeSliderSlider() + { + Slider? slider1 = null; + SliderPath? slider1Path = null; + Slider? slider2 = null; + + AddStep("select two sliders", () => + { + slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value); + slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime); + EditorClock.Seek(slider1.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider1); + EditorBeatmap.SelectedHitObjects.Add(slider2); + }); + + mergeSelection(); + + AddAssert("slider created", () => + { + if (slider1 is null || slider2 is null || slider1Path is null) + return false; + + var controlPoints1 = slider1Path.ControlPoints; + var controlPoints2 = slider2.Path.ControlPoints; + (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1]; + + for (int i = 0; i < controlPoints1.Count - 1; i++) + { + args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type); + } + + for (int i = 0; i < controlPoints2.Count; i++) + { + args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type); + } + + return sliderCreatedFor(args); + }); + + AddAssert("samples exist", sliderSampleExist); + + AddAssert("merged slider matches first slider", () => + { + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples) + && mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples) + && mergedSlider.Samples.SequenceEqual(slider1.Samples) + && mergedSlider.SampleControlPoint.IsRedundant(slider1.SampleControlPoint); + }); + + AddAssert("slider end is at same completion for last slider", () => + { + if (slider1Path is null || slider2 is null) + return false; + + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance); + }); + } + + [Test] + public void TestNonMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + Spinner? spinner = null; + + AddStep("select first two circles and spinner", () => + { + circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle); + circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1); + spinner = (Spinner)EditorBeatmap.HitObjects.First(h => h is Spinner); + EditorClock.Seek(spinner.StartTime); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + EditorBeatmap.SelectedHitObjects.Add(spinner); + }); + + mergeSelection(); + + AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor( + (pos: circle1.Position, pathType: PathType.Linear), + (pos: circle2.Position, pathType: null))); + + AddAssert("samples exist", sliderSampleExist); + + AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner)); + } + + [Test] + public void TestIllegalMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + + AddStep("add two circles on the same position", () => + { + circle1 = new HitCircle(); + circle2 = new HitCircle { Position = circle1.Position + Vector2.UnitX }; + EditorClock.Seek(0); + EditorBeatmap.Add(circle1); + EditorBeatmap.Add(circle2); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + moveMouseToHitObject(1); + AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection")); + mergeSelection(); + AddAssert("circles not merged", () => circle1 is not null && circle2 is not null + && EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2)); + } + + [Test] + public void TestSameStartTimeMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + + AddStep("add two circles at the same time", () => + { + circle1 = new HitCircle(); + circle2 = new HitCircle { Position = circle1.Position + 100 * Vector2.UnitX }; + EditorClock.Seek(0); + EditorBeatmap.Add(circle1); + EditorBeatmap.Add(circle2); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + moveMouseToHitObject(1); + AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); + + mergeSelection(); + + AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor( + (pos: circle1.Position, pathType: PathType.Linear), + (pos: circle2.Position, pathType: null))); + } + + private void mergeSelection() + { + AddStep("merge selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.M); + InputManager.ReleaseKey(Key.LShift); + InputManager.ReleaseKey(Key.LControl); + }); + } + + private bool sliderCreatedFor(params (Vector2 pos, PathType? pathType)[] expectedControlPoints) + { + if (EditorBeatmap.SelectedHitObjects.Count != 1) + return false; + + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + int i = 0; + + foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints) + { + var controlPoint = mergedSlider.Path.ControlPoints[i++]; + + if (!Precision.AlmostEquals(controlPoint.Position + mergedSlider.Position, pos) || controlPoint.Type != pathType) + return false; + } + + return true; + } + + private bool objectsRestored(params HitObject[] objects) + { + foreach (var hitObject in objects) + { + if (EditorBeatmap.HitObjects.Contains(hitObject)) + return false; + } + + return true; + } + + private bool sliderSampleExist() + { + if (EditorBeatmap.SelectedHitObjects.Count != 1) + return false; + + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + + return mergedSlider.Samples[0] is not null; + } + + private void moveMouseToHitObject(int index) + { + AddStep($"hover mouse over hit object {index}", () => + { + if (EditorBeatmap.HitObjects.Count <= index) + return; + + Vector2 position = ((OsuHitObject)EditorBeatmap.HitObjects[index]).Position; + InputManager.MoveMouseTo(selectionHandler.ToScreenSpace(position)); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index e54ce45ccc..dc74d38cdc 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; @@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Cached] private readonly EditorClock editorClock; @@ -59,10 +63,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } }); - editorClock = new EditorClock(editorBeatmap); - base.Content.Children = new Drawable[] { + editorClock = new EditorClock(editorBeatmap), snapProvider, Content }; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs index f196353f87..6d93c3fcf9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs @@ -182,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { AddStep($"click context menu item \"{contextMenuText}\"", () => { - MenuItem item = visualiser.ContextMenuItems[1].Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + MenuItem item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == "Curve type")?.Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); item?.Action?.Value(); }); 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/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs new file mode 100644 index 0000000000..015952c59a --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -0,0 +1,255 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneSliderSplitting : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private ComposeBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private Slider? slider; + private PathControlPointVisualiser? visualiser; + + private const double split_gap = 100; + + [Test] + public void TestBasicSplit() + { + double endTime = 0; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + EditorBeatmap.Add(slider); + + endTime = slider.EndTime; + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + addContextMenuItemStep("Split control point"); + + AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 2 && + sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], slider.StartTime, endTime + split_gap, + (new Vector2(300, 50), PathType.PerfectCurve), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + )); + + AddStep("undo", () => Editor.Undo()); + AddAssert("original slider restored", () => EditorBeatmap.HitObjects.Count == 1 && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, endTime, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), PathType.PerfectCurve), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + )); + } + + [Test] + public void TestDoubleSplit() + { + double endTime = 0; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.Bezier), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150), PathType.Catmull), + new PathControlPoint(new Vector2(300, 200)), + new PathControlPoint(new Vector2(400, 250)) + }) + }; + + EditorBeatmap.Add(slider); + + endTime = slider.EndTime; + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select first control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + moveMouseToControlPoint(4); + AddStep("select second control point", () => + { + if (visualiser is not null) visualiser.Pieces[4].IsSelected.Value = true; + }); + addContextMenuItemStep("Split 2 control points"); + + AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 3 && + sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], EditorBeatmap.HitObjects[0].GetEndTime() + split_gap, slider.StartTime - split_gap, + (new Vector2(300, 50), PathType.Bezier), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[2], EditorBeatmap.HitObjects[1].GetEndTime() + split_gap, endTime + split_gap * 2, + (new Vector2(400, 200), PathType.Catmull), + (new Vector2(300, 250), null), + (new Vector2(400, 300), null) + )); + } + + [Test] + public void TestSplitRetainsHitsounds() + { + HitSampleInfo? sample = null; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + EditorBeatmap.Add(slider); + }); + + AddStep("add hitsounds", () => + { + if (slider is null) return; + + slider.SampleControlPoint.SampleBank = "soft"; + slider.SampleControlPoint.SampleVolume = 70; + sample = new HitSampleInfo("hitwhistle"); + slider.Samples.Add(sample); + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + addContextMenuItemStep("Split control point"); + AddAssert("sliders have hitsounds", hasHitsounds); + + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0])); + AddStep("remove first slider", () => EditorBeatmap.RemoveAt(0)); + AddStep("undo", () => Editor.Undo()); + AddAssert("sliders have hitsounds", hasHitsounds); + + bool hasHitsounds() => sample is not null && + EditorBeatmap.HitObjects.All(o => o.SampleControlPoint.SampleBank == "soft" && + o.SampleControlPoint.SampleVolume == 70 && + o.Samples.Contains(sample)); + } + + private bool sliderCreatedFor(Slider s, double startTime, double endTime, params (Vector2 pos, PathType? pathType)[] expectedControlPoints) + { + if (!Precision.AlmostEquals(s.StartTime, startTime, 1) || !Precision.AlmostEquals(s.EndTime, endTime, 1)) return false; + + int i = 0; + + foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints) + { + var controlPoint = s.Path.ControlPoints[i++]; + + if (!Precision.AlmostEquals(controlPoint.Position + s.Position, pos) || controlPoint.Type != pathType) + return false; + } + + return true; + } + + private void moveMouseToControlPoint(int index) + { + AddStep($"move mouse to control point {index}", () => + { + if (slider is null || visualiser is null) return; + + Vector2 position = slider.Path.ControlPoints[index].Position + slider.Position; + InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); + }); + } + + private void addContextMenuItemStep(string contextMenuText) + { + AddStep($"click context menu item \"{contextMenuText}\"", () => + { + if (visualiser is null) return; + + MenuItem? item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + + item?.Action?.Value(); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs index 51871dd9e5..0601dc6068 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -148,6 +148,37 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); } + [Test] + public void TestFloatEdgeCaseConversion() + { + Slider slider = null; + + AddStep("select first slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + AddStep("change to these specific circumstances", () => + { + EditorBeatmap.Difficulty.SliderMultiplier = 1; + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(slider.StartTime); + timingPoint.BeatLength = 352.941176470588; + slider.Path.ControlPoints[^1].Position = new Vector2(-110, 16); + slider.Path.ExpectedDistance.Value = 100; + }); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.25, pathPosition: 0.25), + (time: 0.5, pathPosition: 0.5), + (time: 0.75, pathPosition: 0.75), + (time: 1, pathPosition: 1))); + } + private bool streamCreatedFor(Slider slider, params (double time, double pathPosition)[] expectedCircles) { if (EditorBeatmap.HitObjects.Contains(slider)) diff --git a/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs b/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs index 7c4ab2f5f4..b6c8103e3c 100644 --- a/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs @@ -6,7 +6,8 @@ using System.Diagnostics; using System.Linq; using Moq; using NUnit.Framework; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; @@ -19,6 +20,9 @@ namespace osu.Game.Rulesets.Osu.Tests [HeadlessTest] public class LegacyMainCirclePieceTest : OsuTestScene { + [Resolved] + private IRenderer renderer { get; set; } = null!; + private static readonly object?[][] texture_priority_cases = { // default priority lookup @@ -76,7 +80,12 @@ namespace osu.Game.Rulesets.Osu.Tests skin.Setup(s => s.GetTexture(It.IsAny())).CallBase(); skin.Setup(s => s.GetTexture(It.IsIn(textureFilenames), It.IsAny(), It.IsAny())) - .Returns((string componentName, WrapMode _, WrapMode _) => new Texture(1, 1) { AssetName = componentName }); + .Returns((string componentName, WrapMode _, WrapMode _) => + { + var tex = renderer.CreateTexture(1, 1); + tex.AssetName = componentName; + return tex; + }); Child = new DependencyProvidingContainer { @@ -84,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests Child = piece = new TestLegacyMainCirclePiece(priorityLookup), }; - var sprites = this.ChildrenOfType().Where(s => s.Texture.AssetName != null).DistinctBy(s => s.Texture.AssetName).ToArray(); + var sprites = this.ChildrenOfType().Where(s => !string.IsNullOrEmpty(s.Texture.AssetName)).DistinctBy(s => s.Texture.AssetName).ToArray(); Debug.Assert(sprites.Length <= 2); }); diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs index 4f005a0c70..d3cb3bcf59 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.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.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests.Mods diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs index 3d59e4fb51..521c10c10c 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs @@ -1,9 +1,8 @@ // 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.Game.Beatmaps; using osu.Game.Beatmaps.Timing; @@ -127,7 +126,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods /// Ensures alternation is reset before the first hitobject after a break. /// [Test] - public void TestInputSingularWithBreak() => CreateModTest(new ModTestData + public void TestInputSingularWithBreak([Values] bool pressBeforeSecondObject) => CreateModTest(new ModTestData { Mod = new OsuModAlternate(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2, @@ -157,21 +156,26 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }, } }, - ReplayFrames = new List + ReplayFrames = new ReplayFrame[] { // first press to start alternate lock. - new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), - new OsuReplayFrame(501, new Vector2(100)), - // press same key after break but before hit object. - new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.LeftButton), - new OsuReplayFrame(2251, new Vector2(300, 100)), + new OsuReplayFrame(450, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(451, new Vector2(100)), // press same key at second hitobject and ensure it has been hit. - new OsuReplayFrame(2500, new Vector2(500, 100), OsuAction.LeftButton), - new OsuReplayFrame(2501, new Vector2(500, 100)), + new OsuReplayFrame(2450, new Vector2(500, 100), OsuAction.LeftButton), + new OsuReplayFrame(2451, new Vector2(500, 100)), // press same key at third hitobject and ensure it has been missed. - new OsuReplayFrame(3000, new Vector2(500, 100), OsuAction.LeftButton), - new OsuReplayFrame(3001, new Vector2(500, 100)), - } + new OsuReplayFrame(2950, new Vector2(500, 100), OsuAction.LeftButton), + new OsuReplayFrame(2951, new Vector2(500, 100)), + }.Concat(!pressBeforeSecondObject + ? Enumerable.Empty() + : new ReplayFrame[] + { + // press same key after break but before hit object. + new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.LeftButton), + new OsuReplayFrame(2251, new Vector2(300, 100)), + } + ).ToList() }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 378b71ccf7..3563995234 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -1,10 +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 System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -35,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private void runSpmTest(Mod mod) { - SpinnerSpmCalculator spmCalculator = null; + SpinnerSpmCalculator? spmCalculator = null; CreateModTest(new ModTestData { @@ -61,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods return spmCalculator != null; }); - AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCalculator.Result.Value, 477, 5)); + AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCalculator.AsNonNull().Result.Value, 477, 5)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs index 80dc83d7dc..88b6b9dd56 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.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 NUnit.Framework; @@ -10,7 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -90,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods if (!objects.Any()) return false; - return objects.All(o => Precision.AlmostEquals(o.ChildrenOfType().First().Children.OfType().Single().Scale.X, target)); + return objects.All(o => Precision.AlmostEquals(o.ChildrenOfType().First().Scale.X, target)); } private bool checkSomeHit() diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs index e1bed5153b..8df8afe147 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.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 NUnit.Framework; using osu.Framework.Utils; using osu.Game.Rulesets.Osu.Mods; @@ -24,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = mod, PassCondition = () => Player.ScoreProcessor.JudgedHits >= 2 && - Precision.AlmostEquals(Player.GameplayClockContainer.GameplayClock.Rate, mod.SpeedChange.Value) + Precision.AlmostEquals(Player.GameplayClockContainer.Rate, mod.SpeedChange.Value) }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs new file mode 100644 index 0000000000..704a548c61 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModFlashlight : OsuModTestScene + { + [TestCase(600)] + [TestCase(120)] + [TestCase(1200)] + public void TestFollowDelay(double followDelay) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { FollowDelay = { Value = followDelay } }, PassCondition = () => true }); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.5f)] + [TestCase(2f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs index 5ed25baca3..e692f8ecbc 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.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 NUnit.Framework; @@ -162,7 +160,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private class TestOsuModHidden : OsuModHidden { - public new HitObject FirstObject => base.FirstObject; + public new HitObject? FirstObject => base.FirstObject; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs index 1f1db04c24..9b49e60363 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.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 NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs index 99c9036ac0..68669d1a53 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.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.Testing; @@ -33,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestModCopy() { - OsuModMuted muted = null; + OsuModMuted muted = null!; AddStep("create inversed mod", () => muted = new OsuModMuted { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs index 47e7ad320c..da6fac3269 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs @@ -1,11 +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.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Utils; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -14,6 +13,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods @@ -147,6 +147,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private bool isBreak() => Player.IsBreakTime.Value; - private bool cursorAlphaAlmostEquals(float alpha) => Precision.AlmostEquals(Player.DrawableRuleset.Cursor.Alpha, alpha, 0.1f); + private OsuPlayfield playfield => (OsuPlayfield)Player.DrawableRuleset.Playfield; + + private bool cursorAlphaAlmostEquals(float alpha) => + Precision.AlmostEquals(playfield.Cursor.AsNonNull().Alpha, alpha, 0.1f) && + Precision.AlmostEquals(playfield.Smoke.Alpha, alpha, 0.1f); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs index b7669624ff..985baa8cf5 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.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 NUnit.Framework; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs new file mode 100644 index 0000000000..c24ba6d530 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs @@ -0,0 +1,102 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModRandom : OsuModTestScene + { + [TestCase(1)] + [TestCase(7)] + [TestCase(10)] + public void TestDefaultBeatmap(float angleSharpness) => CreateModTest(new ModTestData + { + Mod = new OsuModRandom + { + AngleSharpness = { Value = angleSharpness } + }, + Autoplay = true, + PassCondition = () => true + }); + + [TestCase(1)] + [TestCase(7)] + [TestCase(10)] + public void TestJumpBeatmap(float angleSharpness) => CreateModTest(new ModTestData + { + Mod = new OsuModRandom + { + AngleSharpness = { Value = angleSharpness } + }, + Beatmap = jumpBeatmap, + Autoplay = true, + PassCondition = () => true + }); + + [TestCase(1)] + [TestCase(7)] + [TestCase(10)] + public void TestStreamBeatmap(float angleSharpness) => CreateModTest(new ModTestData + { + Mod = new OsuModRandom + { + AngleSharpness = { Value = angleSharpness } + }, + Beatmap = streamBeatmap, + Autoplay = true, + PassCondition = () => true + }); + + private OsuBeatmap jumpBeatmap => + createHitCircleBeatmap(new[] { 100, 200, 300, 400 }, 8, 300, 2 * 300); + + private OsuBeatmap streamBeatmap => + createHitCircleBeatmap(new[] { 10, 20, 30, 40, 50, 60, 70, 80 }, 16, 150, 4 * 150); + + private OsuBeatmap createHitCircleBeatmap(IEnumerable spacings, int objectsPerSpacing, int interval, int beatLength) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new TimingControlPoint + { + Time = 0, + BeatLength = beatLength + }); + + var beatmap = new OsuBeatmap + { + BeatmapInfo = new BeatmapInfo + { + StackLeniency = 0, + Difficulty = new BeatmapDifficulty + { + ApproachRate = 8.5f + } + }, + ControlPointInfo = controlPointInfo + }; + + foreach (int spacing in spacings) + { + for (int i = 0; i < objectsPerSpacing; i++) + { + beatmap.HitObjects.Add(new HitCircle + { + StartTime = interval * beatmap.HitObjects.Count, + Position = beatmap.HitObjects.Count % 2 == 0 ? Vector2.Zero : new Vector2(spacing, 0), + NewCombo = i == 0 + }); + } + } + + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRepel.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRepel.cs new file mode 100644 index 0000000000..6bd41e2fa5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRepel.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModRepel : OsuModTestScene + { + [TestCase(0.1f)] + [TestCase(0.5f)] + [TestCase(1)] + public void TestRepel(float strength) + { + CreateModTest(new ModTestData + { + Mod = new OsuModRepel + { + RepulsionStrength = { Value = strength }, + }, + PassCondition = () => true, + Autoplay = false, + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs new file mode 100644 index 0000000000..1aed84be10 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs @@ -0,0 +1,175 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModSingleTap : OsuModTestScene + { + [Test] + public void TestInputSingular() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 2, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 500, + Position = new Vector2(100), + }, + new HitCircle + { + StartTime = 1000, + Position = new Vector2(200, 100), + }, + new HitCircle + { + StartTime = 1500, + Position = new Vector2(300, 100), + }, + new HitCircle + { + StartTime = 2000, + Position = new Vector2(400, 100), + }, + }, + }, + ReplayFrames = new List + { + new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(100)), + new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.LeftButton), + } + }); + + [Test] + public void TestInputAlternating() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 500, + Position = new Vector2(100), + }, + new HitCircle + { + StartTime = 1000, + Position = new Vector2(200, 100), + }, + }, + }, + ReplayFrames = new List + { + new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(100)), + new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.RightButton), + new OsuReplayFrame(1001, new Vector2(200, 100)), + new OsuReplayFrame(1500, new Vector2(300, 100), OsuAction.LeftButton), + new OsuReplayFrame(1501, new Vector2(300, 100)), + new OsuReplayFrame(2000, new Vector2(400, 100), OsuAction.RightButton), + new OsuReplayFrame(2001, new Vector2(400, 100)), + } + }); + + /// + /// Ensures singletapping is reset before the first hitobject after intro. + /// + [Test] + public void TestInputAlternatingAtIntro() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 1, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(100), + }, + }, + }, + ReplayFrames = new List + { + // first press during intro. + new OsuReplayFrame(500, new Vector2(200), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(200)), + // press different key at hitobject and ensure it has been hit. + new OsuReplayFrame(1000, new Vector2(100), OsuAction.RightButton), + } + }); + + /// + /// Ensures singletapping is reset before the first hitobject after a break. + /// + [Test] + public void TestInputAlternatingWithBreak() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2, + Autoplay = false, + Beatmap = new Beatmap + { + Breaks = new List + { + new BreakPeriod(500, 2000), + }, + HitObjects = new List + { + new HitCircle + { + StartTime = 500, + Position = new Vector2(100), + }, + new HitCircle + { + StartTime = 2500, + Position = new Vector2(500, 100), + }, + new HitCircle + { + StartTime = 3000, + Position = new Vector2(500, 100), + }, + } + }, + ReplayFrames = new List + { + // first press to start singletap lock. + new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(100)), + // press different key after break but before hit object. + new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.RightButton), + new OsuReplayFrame(2251, new Vector2(300, 100)), + // press same key at second hitobject and ensure it has been hit. + new OsuReplayFrame(2500, new Vector2(500, 100), OsuAction.LeftButton), + new OsuReplayFrame(2501, new Vector2(500, 100)), + // press different key at third hitobject and ensure it has been missed. + new OsuReplayFrame(3000, new Vector2(500, 100), OsuAction.RightButton), + new OsuReplayFrame(3001, new Vector2(500, 100)), + } + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs index 4f6d6376bf..e121e6103d 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.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; using System.Collections.Generic; using System.Linq; @@ -30,8 +28,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestSpinnerAutoCompleted() { - DrawableSpinner spinner = null; - JudgementResult lastResult = null; + DrawableSpinner? spinner = null; + JudgementResult? lastResult = null; CreateModTest(new ModTestData { @@ -63,11 +61,11 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [TestCase(null)] [TestCase(typeof(OsuModDoubleTime))] [TestCase(typeof(OsuModHalfTime))] - public void TestSpinRateUnaffectedByMods(Type additionalModType) + public void TestSpinRateUnaffectedByMods(Type? additionalModType) { var mods = new List { new OsuModSpunOut() }; if (additionalModType != null) - mods.Add((Mod)Activator.CreateInstance(additionalModType)); + mods.Add((Mod)Activator.CreateInstance(additionalModType)!); CreateModTest(new ModTestData { @@ -96,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestSpinnerGetsNoBonusScore() { - DrawableSpinner spinner = null; + DrawableSpinner? spinner = null; List results = new List(); CreateModTest(new ModTestData diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index bb593c2fb3..7e995f2dde 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -17,18 +17,18 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.6972307565739273d, 206, "diffcalc-test")] - [TestCase(1.4484754139145539d, 45, "zero-length-sliders")] + [TestCase(6.7115569159190587d, 206, "diffcalc-test")] + [TestCase(1.4391311903612753d, 45, "zero-length-sliders")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(8.9382559208689809d, 206, "diffcalc-test")] - [TestCase(1.7548875851757628d, 45, "zero-length-sliders")] + [TestCase(8.9757300665532966d, 206, "diffcalc-test")] + [TestCase(1.7437232654020756d, 45, "zero-length-sliders")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.6972307218715166d, 239, "diffcalc-test")] - [TestCase(1.4484754139145537d, 54, "zero-length-sliders")] + [TestCase(6.7115569159190587d, 239, "diffcalc-test")] + [TestCase(1.4391311903612753d, 54, "zero-length-sliders")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png new file mode 100644 index 0000000000..b1380a47a4 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png new file mode 100644 index 0000000000..5f7beae4e9 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs index b5d1c4854c..7f0ecaca2b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs @@ -170,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("setup default legacy skin", () => { - skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + skinManager.CurrentSkinInfo.Value = skinManager.DefaultClassicSkin.SkinInfo; }); }); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index c8d2d07be5..d3e70a0a01 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -11,7 +11,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Testing.Input; using osu.Game.Audio; @@ -25,6 +25,9 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneCursorTrail : OsuTestScene { + [Resolved] + private IRenderer renderer { get; set; } + [Test] public void TestSmoothCursorTrail() { @@ -44,7 +47,7 @@ namespace osu.Game.Rulesets.Osu.Tests { createTest(() => { - var skinContainer = new LegacySkinContainer(false); + var skinContainer = new LegacySkinContainer(renderer, false); var legacyCursorTrail = new LegacyCursorTrail(skinContainer); skinContainer.Child = legacyCursorTrail; @@ -58,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests { createTest(() => { - var skinContainer = new LegacySkinContainer(true); + var skinContainer = new LegacySkinContainer(renderer, true); var legacyCursorTrail = new LegacyCursorTrail(skinContainer); skinContainer.Child = legacyCursorTrail; @@ -82,10 +85,12 @@ namespace osu.Game.Rulesets.Osu.Tests [Cached(typeof(ISkinSource))] private class LegacySkinContainer : Container, ISkinSource { + private readonly IRenderer renderer; private readonly bool disjoint; - public LegacySkinContainer(bool disjoint) + public LegacySkinContainer(IRenderer renderer, bool disjoint) { + this.renderer = renderer; this.disjoint = disjoint; RelativeSizeAxes = Axes.Both; @@ -98,14 +103,14 @@ namespace osu.Game.Rulesets.Osu.Tests switch (componentName) { case "cursortrail": - var tex = new Texture(Texture.WhitePixel.TextureGL); + var tex = new Texture(renderer.WhitePixel); if (disjoint) tex.ScaleAdjust = 1 / 25f; return tex; case "cursormiddle": - return disjoint ? null : Texture.WhitePixel; + return disjoint ? null : renderer.WhitePixel; } return null; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs index 305678bb62..1e625cd4e6 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs @@ -195,7 +195,7 @@ namespace osu.Game.Rulesets.Osu.Tests { var drawableObject = getFunc.Invoke(); - hitObjectContainer.Remove(drawableObject); + hitObjectContainer.Remove(drawableObject, false); followPointRenderer.RemoveFollowPoints(drawableObject.HitObject); }); } @@ -212,7 +212,7 @@ namespace osu.Game.Rulesets.Osu.Tests else targetTime = getObject(hitObjectContainer.Count - 1).HitObject.StartTime + 1; - hitObjectContainer.Remove(toReorder); + hitObjectContainer.Remove(toReorder, false); toReorder.HitObject.StartTime = targetTime; hitObjectContainer.Add(toReorder); }); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 2edacee9ac..360815afbf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -10,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.Input; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index cc69054e23..be224b88ce 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -58,10 +58,11 @@ namespace osu.Game.Rulesets.Osu.Tests private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) { - var drawable = createSingle(circleSize, auto, timeOffset, positionOffset); - var playfield = new TestOsuPlayfield(); - playfield.Add(drawable); + + for (double t = timeOffset; t < timeOffset + 60000; t += 2000) + playfield.Add(createSingle(circleSize, auto, t, positionOffset)); + return playfield; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs index 0cf2ec6b7e..57734236da 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - Child = new SkinProvidingContainer(new DefaultSkin(null)) + Child = new SkinProvidingContainer(new TrianglesSkin(null)) { RelativeSizeAxes = Axes.Both, Child = drawableHitCircle = new DrawableHitCircle(hitCircle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index dd110662b2..036f90b962 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -12,7 +12,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Timing; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index 08a62fe3ae..728aa27da2 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; @@ -68,10 +69,8 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("create slider", () => { - var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); - tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1"; - - var provider = Ruleset.Value.CreateInstance().CreateLegacySkinProvider(tintingSkin, Beatmap.Value.Beatmap); + var skin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); + var provider = Ruleset.Value.CreateInstance().CreateSkinTransformer(skin, Beatmap.Value.Beatmap); Child = new SkinProvidingContainer(provider) { @@ -92,10 +91,10 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("set accent white", () => dho.AccentColour.Value = Color4.White); - AddAssert("ball is white", () => dho.ChildrenOfType().Single().AccentColour == Color4.White); + AddAssert("ball is white", () => dho.ChildrenOfType().Single().BallColour == Color4.White); AddStep("set accent red", () => dho.AccentColour.Value = Color4.Red); - AddAssert("ball is red", () => dho.ChildrenOfType().Single().AccentColour == Color4.Red); + AddAssert("ball is red", () => dho.ChildrenOfType().Single().BallColour == Color4.Red); } private Slider prepareObject(Slider slider) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs new file mode 100644 index 0000000000..1cb64b71fc --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs @@ -0,0 +1,136 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Framework.Logging; +using osu.Framework.Testing.Input; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneSmoke : OsuSkinnableTestScene + { + [Test] + public void TestSmoking() + { + addStep("Create short smoke", 2_000); + addStep("Create medium smoke", 5_000); + addStep("Create long smoke", 10_000); + } + + private void addStep(string stepName, double duration) + { + var smokeContainers = new List(); + + AddStep(stepName, () => + { + smokeContainers.Clear(); + SetContents(_ => + { + smokeContainers.Add(new TestSmokeContainer + { + Duration = duration, + RelativeSizeAxes = Axes.Both + }); + + return new SmokingInputManager + { + Duration = duration, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.95f), + Child = smokeContainers[^1], + }; + }); + }); + + AddUntilStep("Until skinnable expires", () => + { + if (smokeContainers.Count == 0) + return false; + + Logger.Log("How many: " + smokeContainers.Count); + + foreach (var smokeContainer in smokeContainers) + { + if (smokeContainer.Children.Count != 0) + return false; + } + + return true; + }); + } + + private class SmokingInputManager : ManualInputManager + { + public double Duration { get; init; } + + private double? startTime; + + public SmokingInputManager() + { + UseParentInput = false; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + MoveMouseTo(ToScreenSpace(DrawSize / 2)); + } + + protected override void Update() + { + base.Update(); + + const float spin_angle = 4 * MathF.PI; + + startTime ??= Time.Current; + + float fraction = (float)((Time.Current - startTime) / Duration); + + float angle = fraction * spin_angle; + float radius = fraction * Math.Min(DrawSize.X, DrawSize.Y) / 2; + + Vector2 pos = radius * new Vector2(MathF.Cos(angle), MathF.Sin(angle)) + DrawSize / 2; + MoveMouseTo(ToScreenSpace(pos)); + } + } + + private class TestSmokeContainer : SmokeContainer + { + public double Duration { get; init; } + + private bool isPressing; + private bool isFinished; + + private double? startTime; + + protected override void Update() + { + base.Update(); + + startTime ??= Time.Current + 0.1; + + if (!isPressing && !isFinished && Time.Current > startTime) + { + OnPressed(new KeyBindingPressEvent(new InputState(), OsuAction.Smoke)); + isPressing = true; + isFinished = false; + } + + if (isPressing && Time.Current > startTime + Duration) + { + OnReleased(new KeyBindingReleaseEvent(new InputState(), OsuAction.Smoke)); + isPressing = false; + isFinished = true; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index d1796f2231..5fa4e24f5e 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -1,18 +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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Objects; @@ -36,17 +32,16 @@ namespace osu.Game.Rulesets.Osu.Tests private const double spinner_duration = 6000; [Resolved] - private AudioManager audioManager { get; set; } + private AudioManager audioManager { get; set; } = null!; protected override bool Autoplay => true; protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); - private DrawableSpinner drawableSpinner; - private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); + private DrawableSpinner drawableSpinner = null!; [SetUpSteps] public override void SetUpSteps() @@ -67,12 +62,12 @@ namespace osu.Game.Rulesets.Osu.Tests { trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f); }); - AddAssert("is disc rotation not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, 100)); - AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100)); + AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100)); + AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.Not.EqualTo(0).Within(100)); addSeekStep(0); - AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, trackerRotationTolerance)); - AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100)); + AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance)); + AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(0).Within(100)); } [Test] @@ -80,7 +75,6 @@ namespace osu.Game.Rulesets.Osu.Tests { double finalCumulativeTrackerRotation = 0; double finalTrackerRotation = 0, trackerRotationTolerance = 0; - double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; addSeekStep(spinner_start_time + 5000); AddStep("retrieve disc rotation", () => @@ -88,11 +82,6 @@ namespace osu.Game.Rulesets.Osu.Tests finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f); }); - AddStep("retrieve spinner symbol rotation", () => - { - finalSpinnerSymbolRotation = spinnerSymbol.Rotation; - spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); - }); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); addSeekStep(spinner_start_time + 2500); @@ -100,20 +89,16 @@ namespace osu.Game.Rulesets.Osu.Tests // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. // due to the exponential damping applied we're allowing a larger margin of error of about 10% // (5% relative to the final rotation value, but we're half-way through the spin). - () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation / 2, trackerRotationTolerance)); - AddAssert("symbol rotation rewound", - () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation / 2, spinnerSymbolRotationTolerance)); + () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance)); AddAssert("is cumulative rotation rewound", // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. - () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); + () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100)); addSeekStep(spinner_start_time + 5000); AddAssert("is disc rotation almost same", - () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); - AddAssert("is symbol rotation almost same", - () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, spinnerSymbolRotationTolerance)); + () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance)); AddAssert("is cumulative rotation almost same", - () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation, 100)); + () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100)); } [Test] @@ -125,7 +110,6 @@ namespace osu.Game.Rulesets.Osu.Tests addSeekStep(5000); AddAssert("disc spin direction correct", () => clockwise ? drawableSpinner.RotationTracker.Rotation > 0 : drawableSpinner.RotationTracker.Rotation < 0); - AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); } private Replay flip(Replay scoreReplay) => new Replay @@ -177,10 +161,10 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpinsPerMinute.Value); addSeekStep(2000); - AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0)); + AddAssert("spm still valid", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(estimatedSpm).Within(1.0)); addSeekStep(1000); - AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0)); + AddAssert("spm still valid", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(estimatedSpm).Within(1.0)); } [TestCase(0.5)] @@ -202,14 +186,14 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("adjust track rate", () => ((MasterGameplayClockContainer)Player.GameplayClockContainer).UserPlaybackRate.Value = rate); addSeekStep(1000); - AddAssert("progress almost same", () => Precision.AlmostEquals(expectedProgress, drawableSpinner.Progress, 0.05)); - AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 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", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 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.Tests/TestSceneTrianglesSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneTrianglesSpinnerRotation.cs new file mode 100644 index 0000000000..80e3af6cc0 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneTrianglesSpinnerRotation.cs @@ -0,0 +1,149 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Replays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneTrianglesSpinnerRotation : TestSceneOsuPlayer + { + private const double spinner_start_time = 100; + private const double spinner_duration = 6000; + + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + [Resolved] + private AudioManager audioManager { get; set; } = null!; + + protected override bool Autoplay => true; + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + private DrawableSpinner drawableSpinner = null!; + private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("set triangles skin", () => skinManager.CurrentSkinInfo.Value = TrianglesSkin.CreateInfo().ToLiveUnmanaged()); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddStep("retrieve spinner", () => drawableSpinner = (DrawableSpinner)Player.DrawableRuleset.Playfield.AllHitObjects.First()); + } + + [Test] + public void TestSymbolMiddleRewindingRotation() + { + double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; + + addSeekStep(spinner_start_time + 5000); + AddStep("retrieve spinner symbol rotation", () => + { + finalSpinnerSymbolRotation = spinnerSymbol.Rotation; + spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); + }); + + addSeekStep(spinner_start_time + 2500); + AddAssert("symbol rotation rewound", + () => spinnerSymbol.Rotation, () => Is.EqualTo(finalSpinnerSymbolRotation / 2).Within(spinnerSymbolRotationTolerance)); + + addSeekStep(spinner_start_time + 5000); + AddAssert("is symbol rotation almost same", + () => spinnerSymbol.Rotation, () => Is.EqualTo(finalSpinnerSymbolRotation).Within(spinnerSymbolRotationTolerance)); + } + + [Test] + public void TestSymbolRotationDirection([Values(true, false)] bool clockwise) + { + if (clockwise) + transformReplay(flip); + + addSeekStep(5000); + AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); + } + + private Replay flip(Replay scoreReplay) => new Replay + { + Frames = scoreReplay + .Frames + .Cast() + .Select(replayFrame => + { + var flippedPosition = new Vector2(OsuPlayfield.BASE_SIZE.X - replayFrame.Position.X, replayFrame.Position.Y); + return new OsuReplayFrame(replayFrame.Time, flippedPosition, replayFrame.Actions.ToArray()); + }) + .Cast() + .ToList() + }; + + private void addSeekStep(double time) + { + AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(100)); + } + + private void transformReplay(Func replayTransformation) => AddStep("set replay", () => + { + var drawableRuleset = this.ChildrenOfType().Single(); + var score = drawableRuleset.ReplayScore; + var transformedScore = new Score + { + ScoreInfo = score.ScoreInfo, + Replay = replayTransformation.Invoke(score.Replay) + }; + drawableRuleset.SetReplayScore(transformedScore); + }); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + StartTime = spinner_start_time, + Duration = spinner_duration + }, + } + }; + + private class ScoreExposedPlayer : TestPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public ScoreExposedPlayer() + : base(false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index 2c0d3fd937..1eb1c85d93 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -1,12 +1,10 @@  - - - + + - - + WinExe diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 0694746cbf..6d1b4d1a15 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -13,8 +13,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators public static class AimEvaluator { private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 2.0; - private const double slider_multiplier = 1.5; + private const double acute_angle_multiplier = 1.95; + private const double slider_multiplier = 1.35; private const double velocity_change_multiplier = 0.75; /// @@ -108,19 +108,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); - // Reward for % distance slowed down compared to previous, paying attention to not award overlap - double nonOverlapVelocityBuff = Math.Abs(prevVelocity - currVelocity) - // do not award overlap - * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, Math.Min(osuCurrObj.LazyJumpDistance, osuLastObj.LazyJumpDistance) / 100)), 2); - - // Choose the largest bonus, multiplied by ratio. - velocityChangeBonus = Math.Max(overlapVelocityBuff, nonOverlapVelocityBuff) * distRatio; + velocityChangeBonus = overlapVelocityBuff * distRatio; // Penalize for rhythm changes. velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); } - if (osuLastObj.TravelTime != 0) + if (osuLastObj.BaseObject is Slider) { // Reward sliders based on velocity. sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 3b0826394c..dabbfcd2fb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -15,11 +15,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators private const double max_opacity_bonus = 0.4; private const double hidden_bonus = 0.2; + private const double min_velocity = 0.5; + private const double slider_multiplier = 1.3; + + private const double min_angle_multiplier = 0.2; + /// /// Evaluates the difficulty of memorising and hitting an object, based on: /// - /// distance between the previous and current object, + /// distance between a number of previous objects and the current object, /// the visual opacity of the current object, + /// the angle made by the current object, + /// length and speed of the current object (for sliders), /// and whether the hidden mod is enabled. /// /// @@ -39,6 +46,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators OsuDifficultyHitObject lastObj = osuCurrent; + double angleRepeatCount = 0.0; + // This is iterating backwards in time from the current object. for (int i = 0; i < Math.Min(current.Index, 10); i++) { @@ -47,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (!(currentObj.BaseObject is Spinner)) { - double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.EndPosition).Length; + double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.StackedEndPosition).Length; cumulativeStrainTime += lastObj.StrainTime; @@ -62,6 +71,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; + + if (currentObj.Angle != null && osuCurrent.Angle != null) + { + // Objects further back in time should count less for the nerf. + if (Math.Abs(currentObj.Angle.Value - osuCurrent.Angle.Value) < 0.02) + angleRepeatCount += Math.Max(1.0 - 0.1 * i, 0.0); + } } lastObj = currentObj; @@ -73,6 +89,29 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (hidden) result *= 1.0 + hidden_bonus; + // Nerf patterns with repeated angles. + result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); + + double sliderBonus = 0.0; + + if (osuCurrent.BaseObject is Slider osuSlider) + { + // Invert the scaling factor to determine the true travel distance independent of circle size. + double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor; + + // Reward sliders based on velocity. + sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); + + // Longer sliders require more memorisation. + sliderBonus *= pixelTravelDistance; + + // Nerf sliders with repeats, as less memorisation is required. + if (osuSlider.RepeatCount > 0) + sliderBonus /= (osuSlider.RepeatCount + 1); + } + + result += sliderBonus * slider_multiplier; + return result; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 3ffd45b588..3bec2346ce 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, double greatWindow) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double lastDelta = lastObj.StrainTime; double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - greatWindow * 0.6) / (greatWindow * 0.6)); + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3)); windowPenalty = Math.Min(1, windowPenalty); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 5187717639..c98f875eb5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators /// and how easily they can be cheesed. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, double greatWindow) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; @@ -35,7 +35,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuNextObj = (OsuDifficultyHitObject)current.Next(0); double strainTime = osuCurrObj.StrainTime; - double greatWindowFull = greatWindow * 2; double doubletapness = 1; // Nerf doubletappable doubles. @@ -45,13 +44,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / greatWindowFull), 2); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2); doubletapness = Math.Pow(speedRatio, 1 - windowRatio); } // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strainTime /= Math.Clamp((strainTime / greatWindowFull) / 0.93, 0.92, 1); + strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); // derive speedBonus for calculation double speedBonus = 1.0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 75d9469da3..1e83d6d820 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.0675; - private double hitWindowGreat; + + public override int Version => 20220902; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -43,8 +44,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; + if (mods.Any(m => m is OsuModTouchDevice)) + { + aimRating = Math.Pow(aimRating, 0.8); + flashlightRating = Math.Pow(flashlightRating, 0.8); + } + if (mods.Any(h => h is OsuModRelax)) + { + aimRating *= 0.9; speedRating = 0.0; + flashlightRating *= 0.7; + } double baseAimPerformance = Math.Pow(5 * Math.Max(1, aimRating / 0.0675) - 4, 3) / 100000; double baseSpeedPerformance = Math.Pow(5 * Math.Max(1, speedRating / 0.0675) - 4, 3) / 100000; @@ -60,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); - double starRating = basePerformance > 0.00001 ? Math.Cbrt(1.12) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; + double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double drainRate = beatmap.Difficulty.DrainRate; @@ -70,6 +81,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + return new OsuDifficultyAttributes { StarRating = starRating, @@ -106,22 +122,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - - hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; - return new Skill[] { new Aim(mods, true), new Aim(mods, false), - new Speed(mods, hitWindowGreat), + new Speed(mods), new Flashlight(mods) }; } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { + new OsuModTouchDevice(), new OsuModDoubleTime(), new OsuModHalfTime(), new OsuModEasy(), diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index c3b7834009..30b56ff769 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { + public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + private double accuracy; private int scoreMaxCombo; private int countGreat; @@ -41,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); effectiveMissCount = calculateEffectiveMissCount(osuAttributes); - double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + double multiplier = PERFORMANCE_BASE_MULTIPLIER; if (score.Mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); @@ -51,10 +53,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (score.Mods.Any(h => h is OsuModRelax)) { - // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. - effectiveMissCount = Math.Min(effectiveMissCount + countOk + countMeh, totalHits); + // https://www.desmos.com/calculator/bc9eybdthb + // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 + // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) + double okMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 1.8) : 1.0); + double mehMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 5) : 1.0); - multiplier *= 0.6; + // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. + effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); } double aimValue = computeAimValue(score, osuAttributes); @@ -82,12 +88,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double rawAim = attributes.AimDifficulty; - - if (score.Mods.Any(m => m is OsuModTouchDevice)) - rawAim = Math.Pow(rawAim, 0.8); - - double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0; + double aimValue = Math.Pow(5.0 * Math.Max(1.0, attributes.AimDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); @@ -103,7 +104,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (attributes.ApproachRate > 10.33) approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); else if (attributes.ApproachRate < 8.0) - approachRateFactor = 0.1 * (8.0 - attributes.ApproachRate); + approachRateFactor = 0.05 * (8.0 - attributes.ApproachRate); + + if (score.Mods.Any(h => h is OsuModRelax)) + approachRateFactor = 0.0; aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. @@ -134,6 +138,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes) { + if (score.Mods.Any(h => h is OsuModRelax)) + return 0.0; + double speedValue = Math.Pow(5.0 * Math.Max(1.0, attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + @@ -174,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); // Scale the speed value with # of 50s to punish doubletapping. - speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); + speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); return speedValue; } @@ -221,12 +228,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (!score.Mods.Any(h => h is OsuModFlashlight)) return 0.0; - double rawFlashlight = attributes.FlashlightDifficulty; - - if (score.Mods.Any(m => m is OsuModTouchDevice)) - rawFlashlight = Math.Pow(rawFlashlight, 0.8); - - double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0; + double flashlightValue = Math.Pow(attributes.FlashlightDifficulty, 2.0) * 25.0; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) @@ -258,14 +260,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); } - // Clamp miss count since it's derived from combo and can be higher than total hits and that breaks some calculations - comboBasedMissCount = Math.Min(comboBasedMissCount, totalHits); + // Clamp miss count to maximum amount of possible breaks + comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss); return Math.Max(countMiss, comboBasedMissCount); } private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private int totalHits => countGreat + countOk + countMeh + countMiss; - private int totalSuccessfulHits => countGreat + countOk + countMeh; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index c8646ec456..6aea00fd35 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -9,16 +9,21 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { public class OsuDifficultyHitObject : DifficultyHitObject { - private const int normalised_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + /// + /// A distance by which all distances should be scaled in order to assume a uniform circle size. + /// + public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private const int min_delta_time = 25; - private const float maximum_slider_radius = normalised_radius * 2.4f; - private const float assumed_slider_radius = normalised_radius * 1.8f; + private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f; + private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; @@ -64,7 +69,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing public double TravelDistance { get; private set; } /// - /// The time taken to travel through , with a minimum value of 25ms for a non-zero distance. + /// The time taken to travel through , with a minimum value of 25ms for objects. /// public double TravelTime { get; private set; } @@ -74,6 +79,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } + /// + /// Retrieves the full hit window for a Great . + /// + public double HitWindowGreat { get; private set; } + private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -86,6 +96,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. StrainTime = Math.Max(DeltaTime, min_delta_time); + if (BaseObject is Slider sliderObject) + { + HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; + } + else + { + HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; + } + setDistances(clockRate); } @@ -123,7 +142,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (BaseObject is Slider currentSlider) { computeSliderCursorPosition(currentSlider); - TravelDistance = currentSlider.LazyTravelDistance; + // Bonus for repeat sliders until a better per nested object strain system can be achieved. + TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); } @@ -132,7 +152,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing return; // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalised_radius / (float)BaseObject.Radius; + float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; if (BaseObject.Radius < 30) { @@ -206,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. var currCursorPosition = slider.StackedPosition; - double scalingFactor = normalised_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. + double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. for (int i = 1; i < slider.NestedHitObjects.Count; i++) { @@ -234,7 +254,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing else if (currMovementObj is SliderRepeat) { // For a slider repeat, assume a tighter movement threshold to better assess repeat sliders. - requiredMovement = normalised_radius; + requiredMovement = NORMALISED_RADIUS; } if (currMovementLength > requiredMovement) @@ -248,8 +268,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (i == slider.NestedHitObjects.Count - 1) slider.LazyEndPosition = currCursorPosition; } - - slider.LazyTravelDistance *= (float)Math.Pow(1 + slider.RepeatCount / 2.5, 1.0 / 2.5); // Bonus for repeat sliders until a better per nested object strain system can be achieved. } private Vector2 getEndCursorPosition(OsuHitObject hitObject) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 9b1fbf9a2e..38e0e5b677 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private double currentStrain; - private double skillMultiplier => 23.25; + private double skillMultiplier => 23.55; private double strainDecayBase => 0.15; private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 84ef109598..40448c444c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills hasHiddenMod = mods.Any(m => m is OsuModHidden); } - private double skillMultiplier => 0.05; + private double skillMultiplier => 0.052; private double strainDecayBase => 0.15; private double currentStrain; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index a156726f94..efe0e136bf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -26,14 +26,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override int ReducedSectionCount => 5; protected override double DifficultyMultiplier => 1.04; - private readonly double greatWindow; private readonly List objectStrains = new List(); - public Speed(Mod[] mods, double hitWindowGreat) + public Speed(Mod[] mods) : base(mods) { - greatWindow = hitWindowGreat; } private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); @@ -43,9 +41,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, greatWindow) * skillMultiplier; + currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; - currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current, greatWindow); + currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); double totalStrain = currentStrain * currentRhythm; @@ -64,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills if (maxStrain == 0) return 0; - return objectStrains.Aggregate((total, next) => total + (1.0 / (1.0 + Math.Exp(-(next / maxStrain * 12.0 - 6.0))))); + return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index 92d83d900e..5dec5d1cb0 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -53,6 +54,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private IBindable sliderPosition; private IBindable sliderScale; + [UsedImplicitly] + private readonly IBindable sliderVersion; + public PathControlPointPiece(Slider slider, PathControlPoint controlPoint) { this.slider = slider; @@ -61,11 +65,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components // we don't want to run the path type update on construction as it may inadvertently change the slider. cachePoints(slider); - slider.Path.Version.BindValueChanged(_ => + sliderVersion = slider.Path.Version.GetBoundCopy(); + + // schedule ensure that updates are only applied after all operations from a single frame are applied. + // this avoids inadvertently changing the slider path type for batch operations. + sliderVersion.BindValueChanged(_ => Scheduler.AddOnce(() => { cachePoints(slider); updatePathType(); - }); + })); controlPoint.Changed += updateMarkerDisplay; 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 0506e8ab8a..94655f3cf7 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private InputManager inputManager; public Action> RemoveControlPointsRequested; + public Action> SplitControlPointsRequested; [Resolved(CanBeNull = true)] private IDistanceSnapProvider snapProvider { get; set; } @@ -104,6 +105,29 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return true; } + private bool splitSelected() + { + List controlPointsToSplitAt = Pieces.Where(p => p.IsSelected.Value && isSplittable(p)).Select(p => p.ControlPoint).ToList(); + + // Ensure that there are any points to be split + if (controlPointsToSplitAt.Count == 0) + return false; + + changeHandler?.BeginChange(); + SplitControlPointsRequested?.Invoke(controlPointsToSplitAt); + changeHandler?.EndChange(); + + // Since pieces are re-used, they will not point to the deleted control points while remaining selected + foreach (var piece in Pieces) + piece.IsSelected.Value = false; + + return true; + } + + private bool isSplittable(PathControlPointPiece p) => + // A slider can only be split on control points which connect two different slider segments. + p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault(); + private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -142,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, @@ -277,11 +303,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { + var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition)); + + Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? Parent.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - slider.Position; + for (int i = 0; i < controlPoints.Count; ++i) { var controlPoint = controlPoints[i]; if (selectedControlPoints.Contains(controlPoint)) - controlPoint.Position = dragStartPositions[i] + (e.MousePosition - e.MouseDownPosition); + controlPoint.Position = dragStartPositions[i] + movementDelta; } } @@ -322,25 +352,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (count == 0) return null; - List items = new List(); + var splittablePieces = selectedPieces.Where(isSplittable).ToList(); + int splittableCount = splittablePieces.Count; + + List curveTypeItems = new List(); if (!selectedPieces.Contains(Pieces[0])) - items.Add(createMenuItemForPathType(null)); + curveTypeItems.Add(createMenuItemForPathType(null)); // todo: hide/disable items which aren't valid for selected points - items.Add(createMenuItemForPathType(PathType.Linear)); - items.Add(createMenuItemForPathType(PathType.PerfectCurve)); - items.Add(createMenuItemForPathType(PathType.Bezier)); - items.Add(createMenuItemForPathType(PathType.Catmull)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Linear)); + curveTypeItems.Add(createMenuItemForPathType(PathType.PerfectCurve)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Bezier)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Catmull)); - return new MenuItem[] + var menuItems = new List { - new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", MenuItemType.Destructive, () => DeleteSelected()), new OsuMenuItem("Curve type") { - Items = items + Items = curveTypeItems } }; + + if (splittableCount > 0) + { + menuItems.Add(new OsuMenuItem($"Split {"control point".ToQuantity(splittableCount, splittableCount > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", + MenuItemType.Destructive, + () => splitSelected())); + } + + menuItems.Add( + new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", + MenuItemType.Destructive, + () => DeleteSelected()) + ); + + return menuItems.ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 139bfe7dd3..dd5335a743 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -16,6 +16,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; using osuTK; using osuTK.Input; @@ -24,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public class SliderPlacementBlueprint : PlacementBlueprint { - public new Objects.Slider HitObject => (Objects.Slider)base.HitObject; + public new Slider HitObject => (Slider)base.HitObject; private SliderBodyPiece bodyPiece; private HitCirclePiece headCirclePiece; @@ -42,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private IDistanceSnapProvider snapProvider { get; set; } public SliderPlacementBlueprint() - : base(new Objects.Slider()) + : base(new Slider()) { RelativeSizeAxes = Axes.Both; @@ -82,10 +83,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders case SliderPlacementState.Initial: BeginPlacement(); - var nearestDifficultyPoint = editorBeatmap.HitObjects.LastOrDefault(h => h.GetEndTime() < HitObject.StartTime)?.DifficultyControlPoint?.DeepClone() as DifficultyControlPoint; + var nearestDifficultyPoint = editorBeatmap.HitObjects + .LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime)? + .DifficultyControlPoint?.DeepClone() as DifficultyControlPoint; HitObject.DifficultyControlPoint = nearestDifficultyPoint ?? new DifficultyControlPoint(); HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); + + // Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation. + // Without re-applying defaults, velocity won't be updated. + ApplyDefaultsToHitObject(); break; case SliderPlacementState.Body: @@ -191,7 +198,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - cursor.Position = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; + var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; } else if (cursor != null) { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index bd11cc826f..265a1d21b1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; @@ -111,7 +112,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true) { - RemoveControlPointsRequested = removeControlPoints + RemoveControlPointsRequested = removeControlPoints, + SplitControlPointsRequested = splitControlPoints }); base.OnSelected(); @@ -161,7 +163,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void OnDrag(DragEvent e) { if (placementControlPoint != null) - placementControlPoint.Position = e.MousePosition - HitObject.Position; + { + var result = snapProvider?.FindSnappedPositionAndTime(ToScreenSpace(e.MousePosition)); + placementControlPoint.Position = ToLocalSpace(result?.ScreenSpacePosition ?? ToScreenSpace(e.MousePosition)) - HitObject.Position; + } } protected override void OnMouseUp(MouseUpEvent e) @@ -249,15 +254,83 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HitObject.Position += first; } + private void splitControlPoints(List controlPointsToSplitAt) + { + // Arbitrary gap in milliseconds to put between split slider pieces + const double split_gap = 100; + + // Ensure that there are any points to be split + if (controlPointsToSplitAt.Count == 0) + return; + + editorBeatmap.SelectedHitObjects.Clear(); + + foreach (var splitPoint in controlPointsToSplitAt) + { + if (splitPoint == controlPoints[0] || splitPoint == controlPoints[^1] || splitPoint.Type is null) + continue; + + // Split off the section of slider before this control point so the remaining control points to split are in the latter part of the slider. + int index = controlPoints.IndexOf(splitPoint); + + if (index <= 0) + continue; + + // Extract the split portion and remove from the original slider. + var splitControlPoints = controlPoints.Take(index + 1).ToList(); + controlPoints.RemoveRange(0, index); + + // Turn the control points which were split off into a new slider. + var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone(); + var difficultyPoint = (DifficultyControlPoint)HitObject.DifficultyControlPoint.DeepClone(); + + var newSlider = new Slider + { + StartTime = HitObject.StartTime, + Position = HitObject.Position + splitControlPoints[0].Position, + NewCombo = HitObject.NewCombo, + SampleControlPoint = samplePoint, + DifficultyControlPoint = difficultyPoint, + LegacyLastTickOffset = HitObject.LegacyLastTickOffset, + Samples = HitObject.Samples.Select(s => s.With()).ToList(), + RepeatCount = HitObject.RepeatCount, + NodeSamples = HitObject.NodeSamples.Select(n => (IList)n.Select(s => s.With()).ToList()).ToList(), + Path = new SliderPath(splitControlPoints.Select(o => new PathControlPoint(o.Position - splitControlPoints[0].Position, o == splitControlPoints[^1] ? null : o.Type)).ToArray()) + }; + + // Increase the start time of the slider before adding the new slider so the new slider is immediately inserted at the correct index and internal state remains valid. + HitObject.StartTime += split_gap; + + editorBeatmap.Add(newSlider); + + HitObject.NewCombo = false; + HitObject.Path.ExpectedDistance.Value -= newSlider.Path.CalculatedDistance; + HitObject.StartTime += newSlider.SpanDuration; + + // In case the remainder of the slider has no length left over, give it length anyways so we don't get a 0 length slider. + if (HitObject.Path.ExpectedDistance.Value <= Precision.DOUBLE_EPSILON) + { + HitObject.Path.ExpectedDistance.Value = null; + } + } + + // Once all required pieces have been split off, the original slider has the final split. + // As a final step, we must reset its control points to have an origin of (0,0). + Vector2 first = controlPoints[0].Position; + foreach (var c in controlPoints) + c.Position -= first; + HitObject.Position += first; + } + private void convertToStream() { - if (editorBeatmap == null || changeHandler == null || beatDivisor == null) + if (editorBeatmap == null || beatDivisor == null) return; var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime); double streamSpacing = timingPoint.BeatLength / beatDivisor.Value; - changeHandler.BeginChange(); + changeHandler?.BeginChange(); int i = 0; double time = HitObject.StartTime; @@ -269,7 +342,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders double positionWithRepeats = (time - HitObject.StartTime) / HitObject.Duration * HitObject.SpanCount(); double pathPosition = positionWithRepeats - (int)positionWithRepeats; // every second span is in the reverse direction - need to reverse the path position. - if (Precision.AlmostBigger(positionWithRepeats % 2, 1)) + if (positionWithRepeats % 2 >= 1) pathPosition = 1 - pathPosition; Vector2 position = HitObject.Position + HitObject.Path.PositionAt(pathPosition); @@ -292,7 +365,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders editorBeatmap.Remove(HitObject); - changeHandler.EndChange(); + changeHandler?.EndChange(); } public override MenuItem[] ContextMenuItems => new MenuItem[] diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 60896b17bf..6b4a6e39d9 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Edit [BackgroundDependencyLoader] private void load() { + // Give a bit of breathing room around the playfield content. + PlayfieldContentContainer.Padding = new MarginPadding(10); + LayerBelowRuleset.AddRange(new Drawable[] { distanceSnapGridContainer = new Container diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 4a6d94f303..eddc1390f0 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -6,8 +6,11 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Extensions; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -15,6 +18,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit { @@ -53,6 +57,17 @@ namespace osu.Game.Rulesets.Osu.Edit referencePathTypes = null; } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed) + { + mergeSelection(); + return true; + } + + return false; + } + public override bool HandleMovement(MoveSelectionEvent moveEvent) { var hitObjects = selectedMovableObjects; @@ -112,11 +127,11 @@ namespace osu.Game.Rulesets.Osu.Edit { didFlip = true; - foreach (var point in slider.Path.ControlPoints) + foreach (var cp in slider.Path.ControlPoints) { - point.Position = new Vector2( - (direction == Direction.Horizontal ? -1 : 1) * point.Position.X, - (direction == Direction.Vertical ? -1 : 1) * point.Position.Y + cp.Position = new Vector2( + (direction == Direction.Horizontal ? -1 : 1) * cp.Position.X, + (direction == Direction.Vertical ? -1 : 1) * cp.Position.Y ); } } @@ -168,8 +183,8 @@ 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); + foreach (PathControlPoint cp in path.Path.ControlPoints) + cp.Position = RotatePointAroundOrigin(cp.Position, Vector2.Zero, delta); } } @@ -320,7 +335,115 @@ namespace osu.Game.Rulesets.Osu.Edit /// All osu! hitobjects which can be moved/rotated/scaled. /// private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType() - .Where(h => !(h is Spinner)) + .Where(h => h is not Spinner) .ToArray(); + + /// + /// All osu! hitobjects which can be merged. + /// + private OsuHitObject[] selectedMergeableObjects => SelectedItems.OfType() + .Where(h => h is HitCircle or Slider) + .OrderBy(h => h.StartTime) + .ToArray(); + + private void mergeSelection() + { + var mergeableObjects = selectedMergeableObjects; + + if (!canMerge(mergeableObjects)) + return; + + EditorBeatmap.BeginChange(); + + // Have an initial slider object. + var firstHitObject = mergeableObjects[0]; + var mergedHitObject = firstHitObject as Slider ?? new Slider + { + StartTime = firstHitObject.StartTime, + Position = firstHitObject.Position, + NewCombo = firstHitObject.NewCombo, + SampleControlPoint = firstHitObject.SampleControlPoint, + Samples = firstHitObject.Samples, + }; + + if (mergedHitObject.Path.ControlPoints.Count == 0) + { + mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(Vector2.Zero, PathType.Linear)); + } + + // Merge all the selected hit objects into one slider path. + bool lastCircle = firstHitObject is HitCircle; + + foreach (var selectedMergeableObject in mergeableObjects.Skip(1)) + { + if (selectedMergeableObject is IHasPath hasPath) + { + var offset = lastCircle ? selectedMergeableObject.Position - mergedHitObject.Position : mergedHitObject.Path.ControlPoints[^1].Position; + float distanceToLastControlPoint = Vector2.Distance(mergedHitObject.Path.ControlPoints[^1].Position, offset); + + // Calculate the distance required to travel to the expected distance of the merging slider. + mergedHitObject.Path.ExpectedDistance.Value = mergedHitObject.Path.CalculatedDistance + distanceToLastControlPoint + hasPath.Path.Distance; + + // Remove the last control point if it sits exactly on the start of the next control point. + if (Precision.AlmostEquals(distanceToLastControlPoint, 0)) + { + mergedHitObject.Path.ControlPoints.RemoveAt(mergedHitObject.Path.ControlPoints.Count - 1); + } + + mergedHitObject.Path.ControlPoints.AddRange(hasPath.Path.ControlPoints.Select(o => new PathControlPoint(o.Position + offset, o.Type))); + lastCircle = false; + } + else + { + // Turn the last control point into a linear type if this is the first merging circle in a sequence, so the subsequent control points can be inherited path type. + if (!lastCircle) + { + mergedHitObject.Path.ControlPoints.Last().Type = PathType.Linear; + } + + mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(selectedMergeableObject.Position - mergedHitObject.Position)); + mergedHitObject.Path.ExpectedDistance.Value = null; + lastCircle = true; + } + } + + // Make sure only the merged hit object is in the beatmap. + if (firstHitObject is Slider) + { + foreach (var selectedMergeableObject in mergeableObjects.Skip(1)) + { + EditorBeatmap.Remove(selectedMergeableObject); + } + } + else + { + foreach (var selectedMergeableObject in mergeableObjects) + { + EditorBeatmap.Remove(selectedMergeableObject); + } + + EditorBeatmap.Add(mergedHitObject); + } + + // Make sure the merged hitobject is selected. + SelectedItems.Clear(); + SelectedItems.Add(mergedHitObject); + + EditorBeatmap.EndChange(); + } + + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + { + foreach (var item in base.GetContextMenuItemsForSelection(selection)) + yield return item; + + if (canMerge(selectedMergeableObjects)) + yield return new OsuMenuItem("Merge selection", MenuItemType.Destructive, mergeSelection); + } + + private bool canMerge(IReadOnlyList objects) => + objects.Count > 1 + && (objects.Any(h => h is Slider) + || objects.Zip(objects.Skip(1), (h1, h2) => Precision.DefinitelyBigger(Vector2.DistanceSquared(h1.Position, h2.Position), 1)).Any(x => x)); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs index d8471aca1a..2889832ff1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs @@ -49,6 +49,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup private void updateBeatmap() { Beatmap.BeatmapInfo.StackLeniency = stackLeniency.Current.Value; + Beatmap.SaveState(); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.cs b/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.cs index affc0bae6a..4a3b187e83 100644 --- a/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.cs +++ b/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.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 - namespace osu.Game.Rulesets.Osu.Mods { /// diff --git a/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.cs b/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.cs index a108f5fd14..1458abfe05 100644 --- a/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.cs +++ b/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.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 - namespace osu.Game.Rulesets.Osu.Mods { /// diff --git a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs new file mode 100644 index 0000000000..e4e8905722 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs @@ -0,0 +1,117 @@ +// 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.Framework.Graphics; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Utils; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public abstract class InputBlockingMod : Mod, IApplicableToDrawableRuleset, IUpdatableByPlayfield + { + public override double ScoreMultiplier => 1.0; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) }; + public override ModType Type => ModType.Conversion; + + private const double flash_duration = 1000; + + private DrawableRuleset ruleset = null!; + + protected OsuAction? LastAcceptedAction { get; private set; } + + /// + /// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods). + /// + /// + /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time. + /// + private PeriodTracker nonGameplayPeriods = null!; + + private IFrameStableClock gameplayClock = null!; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ruleset = drawableRuleset; + drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this)); + + var periods = new List(); + + if (drawableRuleset.Objects.Any()) + { + periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1)); + + foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) + periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); + + static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); + } + + nonGameplayPeriods = new PeriodTracker(periods); + + gameplayClock = drawableRuleset.FrameStableClock; + } + + public void Update(Playfield playfield) + { + if (LastAcceptedAction != null && nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) + LastAcceptedAction = null; + } + + protected abstract bool CheckValidNewAction(OsuAction action); + + private bool checkCorrectAction(OsuAction action) + { + if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) + return true; + + switch (action) + { + case OsuAction.LeftButton: + case OsuAction.RightButton: + break; + + // Any action which is not left or right button should be ignored. + default: + return true; + } + + if (CheckValidNewAction(action)) + { + LastAcceptedAction = action; + return true; + } + + ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint); + return false; + } + + private class InputInterceptor : Component, IKeyBindingHandler + { + private readonly InputBlockingMod mod; + + public InputInterceptor(InputBlockingMod mod) + { + this.mod = mod; + } + + public bool OnPressed(KeyBindingPressEvent e) + // if the pressed action is incorrect, block it from reaching gameplay. + => !mod.checkCorrectAction(e.Action); + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs index 622d2df432..9bf5d33d4a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs @@ -1,119 +1,21 @@ // 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.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps.Timing; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; -using osu.Game.Screens.Play; -using osu.Game.Utils; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModAlternate : Mod, IApplicableToDrawableRuleset + public class OsuModAlternate : InputBlockingMod { 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 double ScoreMultiplier => 1.0; - public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) }; - public override ModType Type => ModType.Conversion; + 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(); - private const double flash_duration = 1000; - - /// - /// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods). - /// - /// - /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time. - /// - private PeriodTracker nonGameplayPeriods; - - private OsuAction? lastActionPressed; - private DrawableRuleset ruleset; - - private IFrameStableClock gameplayClock; - - public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - ruleset = drawableRuleset; - drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this)); - - var periods = new List(); - - if (drawableRuleset.Objects.Any()) - { - periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1)); - - foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) - periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); - - static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); - } - - nonGameplayPeriods = new PeriodTracker(periods); - - gameplayClock = drawableRuleset.FrameStableClock; - } - - private bool checkCorrectAction(OsuAction action) - { - if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) - { - lastActionPressed = null; - return true; - } - - switch (action) - { - case OsuAction.LeftButton: - case OsuAction.RightButton: - break; - - // Any action which is not left or right button should be ignored. - default: - return true; - } - - if (lastActionPressed != action) - { - // User alternated correctly. - lastActionPressed = action; - return true; - } - - ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint); - return false; - } - - private class InputInterceptor : Component, IKeyBindingHandler - { - private readonly OsuModAlternate mod; - - public InputInterceptor(OsuModAlternate mod) - { - this.mod = mod; - } - - public bool OnPressed(KeyBindingPressEvent e) - // if the pressed action is incorrect, block it from reaching gameplay. - => !mod.checkCorrectAction(e.Action); - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - } + protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction != action; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs index e25845f5ab..ec93f19e17 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs @@ -1,12 +1,11 @@ // 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.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; @@ -18,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 4c9418726c..6772cfe0be 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -1,13 +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.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; @@ -22,19 +21,17 @@ 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 double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised) }; + 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) }; public bool PerformFail() => false; public bool RestartOnFail => false; - private OsuInputManager inputManager; + private OsuInputManager inputManager = null!; - private IFrameStableClock gameplayClock; - - private List replayFrames; + private List replayFrames = null!; private int currentFrame; @@ -42,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). @@ -57,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/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index d562c37541..7c1f6be9ed 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.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; using System.Collections.Generic; using System.Linq; @@ -14,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModAutoplay : ModAutoplay { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs index 71bdd98457..9e71f657ce 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.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.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index 199c735787..4c72667f15 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.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; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,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; @@ -24,16 +23,16 @@ 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; public override ModType Type => ModType.DifficultyIncrease; - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight) }; - private DrawableOsuBlinds blinds; + private DrawableOsuBlinds blinds = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { @@ -55,9 +54,12 @@ namespace osu.Game.Rulesets.Osu.Mods /// /// Black background boxes behind blind panel textures. /// - private Box blackBoxLeft, blackBoxRight; + private Box blackBoxLeft = null!, blackBoxRight = null!; - private Drawable panelLeft, panelRight, bgPanelLeft, bgPanelRight; + private Drawable panelLeft = null!; + private Drawable panelRight = null!; + private Drawable bgPanelLeft = null!; + private Drawable bgPanelRight = null!; private readonly Beatmap beatmap; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs index 656cf95e77..769694baf4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.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; using System.Collections.Generic; using System.Linq; @@ -15,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModCinema : ModCinema { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap), typeof(OsuModRepel) }).ToArray(); public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 00009f4c3d..e021992f86 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.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; using System.Linq; using osu.Framework.Bindables; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs index c4cc0b4f48..371dfe6a1a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index e95e61312e..f6622c268d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -1,11 +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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { @@ -17,15 +15,12 @@ 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 + public override BindableNumber StartScale { get; } = new BindableFloat(2) { MinValue = 1f, MaxValue = 25f, - Default = 2f, - Value = 2f, Precision = 0.1f, }; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index be159523b7..3a6b232f9f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.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 osu.Game.Beatmaps; using osu.Game.Configuration; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs index 2d19305509..700a3f44bc 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs @@ -1,14 +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 osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { public class OsuModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index 90b22e8d9c..281b36e70e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -1,14 +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 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/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index c082805a0e..1a86901d9c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.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; using System.Linq; using osu.Framework.Bindables; @@ -21,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray(); private const double default_follow_delay = 120; @@ -34,26 +32,18 @@ namespace osu.Game.Rulesets.Osu.Mods Precision = default_follow_delay, }; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 2f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 180; + public override float DefaultFlashlightSize => 200; - private OsuFlashlight flashlight; + private OsuFlashlight flashlight = null!; protected override Flashlight CreateFlashlight() => flashlight = new OsuFlashlight(this); @@ -72,7 +62,8 @@ namespace osu.Game.Rulesets.Osu.Mods { followDelay = modFlashlight.FollowDelay.Value; - FlashlightSize = new Vector2(0, GetSizeFor(0)); + FlashlightSize = new Vector2(0, GetSize()); + FlashlightSmoothness = 1.4f; } public void OnSliderTrackingChange(ValueChangedEvent e) @@ -92,9 +83,9 @@ namespace osu.Game.Rulesets.Osu.Mods return base.OnMouseMove(e); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index 34840de983..3d066d3ada 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -1,11 +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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { @@ -17,15 +15,12 @@ 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 + public override BindableNumber StartScale { get; } = new BindableFloat(0.5f) { MinValue = 0f, MaxValue = 0.99f, - Default = 0.5f, - Value = 0.5f, Precision = 0.01f, }; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs index 54c5c56ca6..4769e7660b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index fdddfed4d5..5430929143 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.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; using System.Linq; using osu.Game.Rulesets.Mods; @@ -14,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModHardRock : ModHardRock, IApplicableToHitObject { - public override double ScoreMultiplier => 1.06; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 11ceb0f710..996ee1cddb 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -1,13 +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.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; @@ -24,8 +23,8 @@ 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 double ScoreMultiplier => 1.06; + 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 cee40866b1..38d90eb121 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -1,11 +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 osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; 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; @@ -24,11 +25,9 @@ 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 double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) }; - - private IFrameStableClock gameplayClock; + 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) }; [SettingSource("Attraction strength", "How strong the pull is.", 0)] public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f) @@ -40,8 +39,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; @@ -50,34 +47,34 @@ namespace osu.Game.Rulesets.Osu.Mods public void Update(Playfield playfield) { - var cursorPos = playfield.Cursor.ActiveCursor.DrawPosition; + var cursorPos = playfield.Cursor.AsNonNull().ActiveCursor.DrawPosition; foreach (var drawable in playfield.HitObjectContainer.AliveObjects) { 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 1d822a2d4c..0a54d58718 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs @@ -1,10 +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 System; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -15,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/OsuModMuted.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs index 1d4650a379..5e3ee37b61 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMuted.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.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs index e9be56fcc5..b7838ebaa7 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.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.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -10,6 +8,6 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModNightcore : ModNightcore { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoFail.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoFail.cs index c20fcf0b1b..9f707a5aa6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoFail.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoFail.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; using System.Linq; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs index fe415cb967..d1bbae8e1a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -1,17 +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.Diagnostics; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osu.Game.Utils; @@ -19,19 +18,12 @@ 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; + private PeriodTracker spinnerPeriods = null!; - [SettingSource( - "Hidden at combo", - "The combo count at which the cursor becomes completely hidden", - SettingControlType = typeof(SettingsSlider) - )] - public override BindableInt HiddenComboCount { get; } = new BindableInt + public override BindableInt HiddenComboCount { get; } = new BindableInt(10) { - Default = 10, - Value = 10, MinValue = 0, MaxValue = 50, }; @@ -43,9 +35,15 @@ namespace osu.Game.Rulesets.Osu.Mods public void Update(Playfield playfield) { - bool shouldAlwaysShowCursor = IsBreakTime.Value || spinnerPeriods.IsInAny(playfield.Clock.CurrentTime); + var osuPlayfield = (OsuPlayfield)playfield; + Debug.Assert(osuPlayfield.Cursor != null); + + bool shouldAlwaysShowCursor = IsBreakTime.Value || spinnerPeriods.IsInAny(osuPlayfield.Clock.CurrentTime); float targetAlpha = shouldAlwaysShowCursor ? 1 : ComboBasedAlpha; - playfield.Cursor.Alpha = (float)Interpolation.Lerp(playfield.Cursor.Alpha, targetAlpha, Math.Clamp(playfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + float currentAlpha = (float)Interpolation.Lerp(osuPlayfield.Cursor.Alpha, targetAlpha, Math.Clamp(osuPlayfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + + osuPlayfield.Cursor.Alpha = currentAlpha; + osuPlayfield.Smoke.Alpha = currentAlpha; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs index 44942e9e37..6f1206382a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs @@ -1,11 +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.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -22,6 +21,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override double ScoreMultiplier => 1; + [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] public abstract BindableNumber StartScale { get; } protected virtual float EndScale => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs b/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs index c5795177d0..33581405a6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs @@ -1,13 +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.Linq; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { public class OsuModPerfect : ModPerfect { + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 4f83154728..618fcfe05d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -2,12 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.Utils; @@ -18,46 +24,137 @@ 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(); + [SettingSource("Angle sharpness", "How sharp angles should be", SettingControlType = typeof(SettingsSlider))] + public BindableFloat AngleSharpness { get; } = new BindableFloat(7) + { + MinValue = 1, + MaxValue = 10, + Precision = 0.1f + }; + private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; - private Random? rng; + private Random random = null!; public void ApplyToBeatmap(IBeatmap beatmap) { - if (!(beatmap is OsuBeatmap osuBeatmap)) + if (beatmap is not OsuBeatmap osuBeatmap) return; Seed.Value ??= RNG.Next(); - rng = new Random((int)Seed.Value); + random = new Random((int)Seed.Value); var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects); - float rateOfChangeMultiplier = 0; + // Offsets the angles of all hit objects in a "section" by the same amount. + float sectionOffset = 0; - foreach (var positionInfo in positionInfos) + // Whether the angles are positive or negative (clockwise or counter-clockwise flow). + bool flowDirection = false; + + for (int i = 0; i < positionInfos.Count; i++) { - // rateOfChangeMultiplier only changes every 5 iterations in a combo - // to prevent shaky-line-shaped streams - if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0) - rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; - - if (positionInfo == positionInfos.First()) + if (shouldStartNewSection(osuBeatmap, positionInfos, i)) { - positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); - positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + sectionOffset = getRandomOffset(0.0008f); + flowDirection = !flowDirection; + } + + if (i == 0) + { + positionInfos[i].DistanceFromPrevious = (float)(random.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); + positionInfos[i].RelativeAngle = (float)(random.NextDouble() * 2 * Math.PI - Math.PI); } else { - positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)); + // Offsets only the angle of the current hit object if a flow change occurs. + float flowChangeOffset = 0; + + // Offsets only the angle of the current hit object. + float oneTimeOffset = getRandomOffset(0.002f); + + if (shouldApplyFlowChange(positionInfos, i)) + { + flowChangeOffset = getRandomOffset(0.002f); + flowDirection = !flowDirection; + } + + float totalOffset = + // sectionOffset and oneTimeOffset should mainly affect patterns with large spacing. + (sectionOffset + oneTimeOffset) * positionInfos[i].DistanceFromPrevious + + // flowChangeOffset should mainly affect streams. + flowChangeOffset * (playfield_diagonal - positionInfos[i].DistanceFromPrevious); + + positionInfos[i].RelativeAngle = getRelativeTargetAngle(positionInfos[i].DistanceFromPrevious, totalOffset, flowDirection); } } osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos); } + + private float getRandomOffset(float stdDev) + { + // Range: [0.5, 2] + // Higher angle sharpness -> lower multiplier + float customMultiplier = (1.5f * AngleSharpness.MaxValue - AngleSharpness.Value) / (1.5f * AngleSharpness.MaxValue - AngleSharpness.Default); + + return OsuHitObjectGenerationUtils.RandomGaussian(random, 0, stdDev * customMultiplier); + } + + /// The target distance between the previous and the current . + /// The angle (in rad) by which the target angle should be offset. + /// Whether the relative angle should be positive or negative. + private float getRelativeTargetAngle(float targetDistance, float offset, bool flowDirection) + { + // Range: [0.1, 1] + float angleSharpness = AngleSharpness.Value / AngleSharpness.MaxValue; + // Range: [0, 0.9] + float angleWideness = 1 - angleSharpness; + + // Range: [-60, 30] + float customOffsetX = angleSharpness * 100 - 70; + // Range: [-0.075, 0.15] + float customOffsetY = angleWideness * 0.25f - 0.075f; + + targetDistance += customOffsetX; + float angle = (float)(2.16 / (1 + 200 * Math.Exp(0.036 * (targetDistance - 310 + customOffsetX))) + 0.5); + angle += offset + customOffsetY; + + float relativeAngle = (float)Math.PI - angle; + + return flowDirection ? -relativeAngle : relativeAngle; + } + + /// Whether a new section should be started at the current . + private bool shouldStartNewSection(OsuBeatmap beatmap, IReadOnlyList positionInfos, int i) + { + if (i == 0) + return true; + + // Exclude new-combo-spam and 1-2-combos. + bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 && + positionInfos[i - 1].HitObject.NewCombo; + bool previousObjectWasOnDownbeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject, true); + bool previousObjectWasOnBeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject); + + return (previousObjectStartedCombo && random.NextDouble() < 0.6f) || + previousObjectWasOnDownbeat || + (previousObjectWasOnBeat && random.NextDouble() < 0.4f); + } + + /// Whether a flow change should be applied at the current . + private bool shouldApplyFlowChange(IReadOnlyList positionInfos, int i) + { + // Exclude new-combo-spam and 1-2-combos. + bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 && + positionInfos[i - 1].HitObject.NewCombo; + + return previousObjectStartedCombo && random.NextDouble() < 0.6f; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 2cf8c278ca..753de6231a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -1,12 +1,11 @@ // 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.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; @@ -20,8 +19,10 @@ 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 Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate) }).ToArray(); + 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(); /// /// How early before a hitobject's start time to trigger a hit. @@ -31,9 +32,9 @@ namespace osu.Game.Rulesets.Osu.Mods private bool isDownState; private bool wasLeft; - private OsuInputManager osuInputManager; + private OsuInputManager osuInputManager = null!; - private ReplayState state; + private ReplayState state = null!; private double lastStateChangeTime; private bool hasReplay; @@ -52,7 +53,7 @@ namespace osu.Game.Rulesets.Osu.Mods return; } - osuInputManager.AllowUserPresses = false; + osuInputManager.AllowGameplayInputs = false; } public void Update(Playfield playfield) @@ -134,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Mods wasLeft = !wasLeft; } - state?.Apply(osuInputManager.CurrentState, osuInputManager); + state.Apply(osuInputManager.CurrentState, osuInputManager); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs new file mode 100644 index 0000000000..31a6b69d6b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.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 osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Localisation; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Mods +{ + internal class OsuModRepel : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + { + public override string Name => "Repel"; + public override string Acronym => "RP"; + public override ModType Type => ModType.Fun; + 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) }; + + [SettingSource("Repulsion strength", "How strong the repulsion is.", 0)] + public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f) + { + Precision = 0.05f, + MinValue = 0.05f, + MaxValue = 1.0f, + }; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + // 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; + (drawableRuleset.Playfield as OsuPlayfield)?.FollowPoints.Hide(); + } + + public void Update(Playfield playfield) + { + var cursorPos = playfield.Cursor.AsNonNull().ActiveCursor.DrawPosition; + + foreach (var drawable in playfield.HitObjectContainer.AliveObjects) + { + var destination = Vector2.Clamp(2 * drawable.Position - cursorPos, Vector2.Zero, OsuPlayfield.BASE_SIZE); + + if (drawable.HitObject is Slider thisSlider) + { + var possibleMovementBounds = OsuHitObjectGenerationUtils.CalculatePossibleMovementBounds(thisSlider); + + destination = Vector2.Clamp( + destination, + new Vector2(possibleMovementBounds.Left, possibleMovementBounds.Top), + new Vector2(possibleMovementBounds.Right, possibleMovementBounds.Bottom) + ); + } + + switch (drawable) + { + case DrawableHitCircle circle: + easeTo(playfield.Clock, circle, destination, cursorPos); + break; + + case DrawableSlider slider: + + if (!slider.HeadCircle.Result.HasResult) + easeTo(playfield.Clock, slider, destination, cursorPos); + else + easeTo(playfield.Clock, slider, destination - slider.Ball.DrawPosition, cursorPos); + + break; + } + } + } + + private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos) + { + double dampLength = Vector2.Distance(hitObject.Position, cursorPos) / (0.04 * RepulsionStrength.Value + 0.04); + + 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 new file mode 100644 index 0000000000..91731b25cf --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Localisation; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModSingleTap : InputBlockingMod + { + public override string Name => @"Single Tap"; + public override string Acronym => @"SG"; + 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 16e7780af0..b0533d0cfa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -1,11 +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.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; @@ -20,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 61028a1ee8..9708800daa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -1,11 +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.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; @@ -20,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 565ff415be..67b19124e1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -1,11 +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 System.Linq; using System.Threading; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -25,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/OsuModSuddenDeath.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs index 4eb7659152..429fe30fc5 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.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; using System.Linq; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index f03bcffdc8..406968ba08 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -1,14 +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 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; @@ -41,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[] @@ -54,11 +53,10 @@ namespace osu.Game.Rulesets.Osu.Mods }).ToArray(); [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] - public Bindable Seed { get; } = new Bindable - { - Default = null, - Value = null - }; + public Bindable Seed { get; } = new Bindable(); + + [SettingSource("Metronome ticks", "Whether a metronome beat should play in the background")] + public Bindable Metronome { get; } = new BindableBool(true); #region Constants @@ -96,11 +94,7 @@ namespace osu.Game.Rulesets.Osu.Mods #region Private Fields - private ControlPointInfo controlPointInfo; - - private List originalHitObjects; - - private Random rng; + private ControlPointInfo controlPointInfo = null!; #endregion @@ -171,16 +165,17 @@ namespace osu.Game.Rulesets.Osu.Mods public override void ApplyToBeatmap(IBeatmap beatmap) { Seed.Value ??= RNG.Next(); - rng = new Random(Seed.Value.Value); + + var rng = new Random(Seed.Value.Value); var osuBeatmap = (OsuBeatmap)beatmap; if (osuBeatmap.HitObjects.Count == 0) return; controlPointInfo = osuBeatmap.ControlPointInfo; - originalHitObjects = osuBeatmap.HitObjects.OrderBy(x => x.StartTime).ToList(); - var hitObjects = generateBeats(osuBeatmap) + var originalHitObjects = osuBeatmap.HitObjects.OrderBy(x => x.StartTime).ToList(); + var hitObjects = generateBeats(osuBeatmap, originalHitObjects) .Select(beat => { var newCircle = new HitCircle(); @@ -189,18 +184,18 @@ namespace osu.Game.Rulesets.Osu.Mods return (OsuHitObject)newCircle; }).ToList(); - addHitSamples(hitObjects); + addHitSamples(hitObjects, originalHitObjects); - fixComboInfo(hitObjects); + fixComboInfo(hitObjects, originalHitObjects); - randomizeCirclePos(hitObjects); + randomizeCirclePos(hitObjects, rng); osuBeatmap.HitObjects = hitObjects; base.ApplyToBeatmap(beatmap); } - private IEnumerable generateBeats(IBeatmap beatmap) + private IEnumerable generateBeats(IBeatmap beatmap, IReadOnlyCollection originalHitObjects) { double startTime = originalHitObjects.First().StartTime; double endTime = originalHitObjects.Last().GetEndTime(); @@ -213,7 +208,7 @@ namespace osu.Game.Rulesets.Osu.Mods // Remove beats before startTime .Where(beat => almostBigger(beat, startTime)) // Remove beats during breaks - .Where(beat => !isInsideBreakPeriod(beatmap.Breaks, beat)) + .Where(beat => !isInsideBreakPeriod(originalHitObjects, beatmap.Breaks, beat)) .ToList(); // Remove beats that are too close to the next one (e.g. due to timing point changes) @@ -228,7 +223,7 @@ namespace osu.Game.Rulesets.Osu.Mods return beats; } - private void addHitSamples(IEnumerable hitObjects) + private void addHitSamples(IEnumerable hitObjects, List originalHitObjects) { foreach (var obj in hitObjects) { @@ -240,7 +235,7 @@ namespace osu.Game.Rulesets.Osu.Mods } } - private void fixComboInfo(List hitObjects) + private void fixComboInfo(List hitObjects, List originalHitObjects) { // Copy combo indices from an original object at the same time or from the closest preceding object // (Objects lying between two combos are assumed to belong to the preceding combo) @@ -274,7 +269,7 @@ namespace osu.Game.Rulesets.Osu.Mods } } - private void randomizeCirclePos(IReadOnlyList hitObjects) + private void randomizeCirclePos(IReadOnlyList hitObjects, Random rng) { if (hitObjects.Count == 0) return; @@ -341,7 +336,8 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.Overlays.Add(new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + if (Metronome.Value) + drawableRuleset.Overlays.Add(new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); } #endregion @@ -355,16 +351,19 @@ namespace osu.Game.Rulesets.Osu.Mods /// The given time is also considered to be inside a break if it is earlier than the /// start time of the first original hit object after the break. /// + /// Hit objects order by time. /// The breaks of the beatmap. /// The time to be checked.= - private bool isInsideBreakPeriod(IEnumerable breaks, double time) + private bool isInsideBreakPeriod(IReadOnlyCollection originalHitObjects, IEnumerable breaks, double time) { return breaks.Any(breakPeriod => { - var firstObjAfterBreak = originalHitObjects.First(obj => almostBigger(obj.StartTime, breakPeriod.EndTime)); + OsuHitObject? firstObjAfterBreak = originalHitObjects.FirstOrDefault(obj => almostBigger(obj.StartTime, breakPeriod.EndTime)); return almostBigger(time, breakPeriod.StartTime) - && definitelyBigger(firstObjAfterBreak.StartTime, time); + // There should never really be a break section with no objects after it, but we've seen crashes from users with malformed beatmaps, + // so it's best to guard against this. + && (firstObjAfterBreak == null || definitelyBigger(firstObjAfterBreak.StartTime, time)); }); } @@ -405,7 +404,7 @@ namespace osu.Game.Rulesets.Osu.Mods /// The list of hit objects in a beatmap, ordered by StartTime /// The point in time to get samples for /// Hit samples - private IList getSamplesAtTime(IEnumerable hitObjects, double time) + private IList? getSamplesAtTime(IEnumerable hitObjects, double time) { // Get a hit object that // either has StartTime equal to the target time diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index f8c1e1639d..fd5c46a226 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -1,8 +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 - +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods @@ -11,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 6e5dd45a7a..25d05a88a8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -1,11 +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.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; @@ -18,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) }; @@ -59,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Mods } } - private void applyCirclePieceState(DrawableOsuHitObject hitObject, IDrawable hitCircle = null) + private void applyCirclePieceState(DrawableOsuHitObject hitObject, IDrawable? hitCircle = null) { var h = hitObject.HitObject; using (hitObject.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index 5a08df3803..2354cd50ae 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -1,11 +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.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; @@ -20,9 +19,9 @@ 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) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel) }; private float theta; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 3fba2cefd2..a45338d91f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -1,11 +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 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; using osu.Game.Rulesets.Objects.Types; @@ -20,12 +21,19 @@ 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) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised), typeof(OsuModRepel) }; - private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles - private const int wiggle_strength = 10; // Higher = stronger wiggles + private const int wiggle_duration = 100; // (ms) Higher = fewer wiggles + + [SettingSource("Strength", "Multiplier applied to the wiggling strength.")] + public BindableDouble Strength { get; } = new BindableDouble(1) + { + MinValue = 0.1f, + MaxValue = 2f, + Precision = 0.1f + }; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) => drawableOnApplyCustomUpdateState(hitObject, state); @@ -49,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Mods void wiggle() { float nextAngle = (float)(objRand.NextDouble() * 2 * Math.PI); - float nextDist = (float)(objRand.NextDouble() * wiggle_strength); + float nextDist = (float)(objRand.NextDouble() * Strength.Value * 7); drawable.MoveTo(new Vector2((float)(nextDist * Math.Cos(nextAngle) + origin.X), (float)(nextDist * Math.Sin(nextAngle) + origin.Y)), wiggle_duration); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 0306c99fd5..1a9d12e860 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -1,9 +1,8 @@ // 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.Diagnostics; using osu.Framework.Graphics; using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects; @@ -21,32 +20,36 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections public const int SPACING = 32; public const double PREEMPT = 800; - public DrawablePool Pool; + public DrawablePool? Pool { private get; set; } protected override void OnApply(FollowPointLifetimeEntry entry) { base.OnApply(entry); - entry.Invalidated += onEntryInvalidated; - refreshPoints(); + entry.Invalidated += scheduleRefresh; + + // Our clock may not be correct at this point if `LoadComplete` has not run yet. + // Without a schedule, animations referencing FollowPoint's clock (see `IAnimationTimeReference`) would be incorrect on first pool usage. + scheduleRefresh(); } protected override void OnFree(FollowPointLifetimeEntry entry) { base.OnFree(entry); - entry.Invalidated -= onEntryInvalidated; + entry.Invalidated -= scheduleRefresh; // Return points to the pool. ClearInternal(false); } - private void onEntryInvalidated() => Scheduler.AddOnce(refreshPoints); - - private void refreshPoints() + private void scheduleRefresh() => Scheduler.AddOnce(() => { + Debug.Assert(Pool != null); + ClearInternal(false); var entry = Entry; + if (entry?.End == null) return; OsuHitObject start = entry.Start; @@ -95,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections } entry.LifetimeEnd = finalTransformEndTime; - } + }); /// /// Computes the fade time of follow point positioned between two hitobjects. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index 4949abccab..306b034645 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { InternalChildren = new Drawable[] { - connectionPool = new DrawablePool(1, 200), + connectionPool = new DrawablePool(10, 200), pointPool = new DrawablePool(50, 1000) }; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 23e5cb0ad7..23db29b9a6 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; @@ -47,12 +48,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } + private ShakeContainer shakeContainer; + [BackgroundDependencyLoader] private void load() { Origin = Anchor.Centre; - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { scaleContainer = new Container { @@ -72,22 +75,30 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return true; }, }, - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()) + shakeContainer = new ShakeContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - ApproachCircle = new ProxyableSkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ApproachCircle), _ => new DefaultApproachCircle()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + ShakeDuration = 30, RelativeSizeAxes = Axes.Both, - Alpha = 0, - Scale = new Vector2(4), + Children = new Drawable[] + { + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ApproachCircle = new ProxyableSkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ApproachCircle), _ => new DefaultApproachCircle()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Scale = new Vector2(4), + } + } } } }, - }; + }); Size = HitArea.DrawSize; @@ -123,6 +134,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } + public override void Shake() => shakeContainer.Shake(); + protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); @@ -139,7 +152,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (result == HitResult.None || CheckHittable?.Invoke(this, Time.Current) == false) { - Shake(Math.Abs(timeOffset) - HitObject.HitWindows.WindowFor(HitResult.Miss)); + Shake(); return; } @@ -191,12 +204,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // todo: temporary / arbitrary, used for lifetime optimisation. this.Delay(800).FadeOut(); - // in the case of an early state change, the fade should be expedited to the current point in time. - if (HitStateUpdateTime < HitObject.StartTime) - ApproachCircle.FadeOut(50); - switch (state) { + default: + ApproachCircle.FadeOut(); + break; + case ArmedState.Idle: HitArea.HitAction = null; break; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 360b28f69f..d9d0d28477 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -6,17 +6,18 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Osu.Scoring; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableOsuHitObject : DrawableHitObject + public abstract class DrawableOsuHitObject : DrawableHitObject { public readonly IBindable PositionBindable = new Bindable(); public readonly IBindable StackHeightBindable = new Bindable(); @@ -34,8 +35,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// public Func CheckHittable; - private ShakeContainer shakeContainer; - protected DrawableOsuHitObject(OsuHitObject hitObject) : base(hitObject) { @@ -45,12 +44,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void load() { Alpha = 0; - - base.AddInternal(shakeContainer = new ShakeContainer - { - ShakeDuration = 30, - RelativeSizeAxes = Axes.Both - }); } protected override void OnApply() @@ -73,18 +66,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.UnbindFrom(HitObject.ScaleBindable); } - // Forward all internal management to shakeContainer. - // This is a bit ugly but we don't have the concept of InternalContent so it'll have to do for now. (https://github.com/ppy/osu-framework/issues/1690) - protected override void AddInternal(Drawable drawable) => shakeContainer.Add(drawable); - protected override void ClearInternal(bool disposeChildren = true) => shakeContainer.Clear(disposeChildren); - protected override bool RemoveInternal(Drawable drawable) => shakeContainer.Remove(drawable); + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // Dim should only be applied at a top level, as it will be implicitly applied to nested objects. + if (ParentHitObject == null) + { + // Of note, no one noticed this was missing for years, but it definitely feels like it should still exist. + // For now this is applied across all skins, and matches stable. + // For simplicity, dim colour is applied to the DrawableHitObject itself. + // We may need to make a nested container setup if this even causes a usage conflict (ie. with a mod). + this.FadeColour(new Color4(195, 195, 195, 255)); + using (BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) + this.FadeColour(Color4.White, 100); + } + } protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; private OsuInputManager osuActionInputManager; internal OsuInputManager OsuActionInputManager => osuActionInputManager ??= GetContainingInputManager() as OsuInputManager; - public virtual void Shake(double maximumLength) => shakeContainer.Shake(maximumLength); + /// + /// Shake the hit object in case it was clicked far too early or late (aka "note lock"). + /// + public virtual void Shake() { } /// /// Causes this to get missed, disregarding all conditions in implementations of . diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 91bb7f95f6..785d15c15b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -11,14 +11,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -34,6 +33,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public SkinnableDrawable Body { get; private set; } + private ShakeContainer shakeContainer; + /// /// A target container which can be used to add top level elements to the slider's display. /// Intended to be used for proxy purposes only. @@ -74,17 +75,26 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [BackgroundDependencyLoader] private void load() { - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { - Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), - tailContainer = new Container { RelativeSizeAxes = Axes.Both }, - tickContainer = new Container { RelativeSizeAxes = Axes.Both }, - repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + shakeContainer = new ShakeContainer + { + ShakeDuration = 30, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + tickContainer = new Container { RelativeSizeAxes = Axes.Both }, + repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + } + }, + // slider head is not included in shake as it handles hit detection, and handles its own shaking. headContainer = new Container { RelativeSizeAxes = Axes.Both }, OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, }, Ball, slidingSample = new PausableSkinnableSound { Looping = true } - }; + }); PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); @@ -94,7 +104,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { foreach (var drawableHitObject in NestedHitObjects) drawableHitObject.AccentColour.Value = colour.NewValue; - updateBallTint(); }, true); Tracking.BindValueChanged(updateSlidingSample); @@ -109,6 +118,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables PathVersion.BindTo(HitObject.Path.Version); } + public override void Shake() => shakeContainer.Shake(); + protected override void OnFree() { base.OnFree(); @@ -243,22 +254,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables SliderBody?.RecyclePath(); } - protected override void ApplySkin(ISkinSource skin, bool allowFallback) - { - base.ApplySkin(skin, allowFallback); - - updateBallTint(); - } - - private void updateBallTint() - { - if (CurrentSkin == null) - return; - - bool allowBallTint = CurrentSkin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; - Ball.AccentColour = allowBallTint ? AccentColour.Value : Color4.White; - } - protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered || Time.Current < HitObject.EndTime) @@ -317,21 +312,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateHitStateTransforms(state); - const float fade_out_time = 450; - - // intentionally pile on an extra FadeOut to make it happen much faster. - Ball.FadeOut(fade_out_time / 4, Easing.Out); + const float fade_out_time = 240; switch (state) { case ArmedState.Hit: - Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); if (SliderBody?.SnakingOut.Value == true) Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear. break; } - this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); + this.FadeOut(fade_out_time).Expire(); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 7bde60b39d..de6ca7dd38 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -11,27 +11,20 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition, IHasAccentColour + public class DrawableSliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition { + public const float FOLLOW_AREA = 2.4f; + public Func GetInitialHitAction; - public Color4 AccentColour - { - get => ball.Colour; - set => ball.Colour = value; - } - - private Drawable followCircle; private Drawable followCircleReceptor; private DrawableSlider drawableSlider; private Drawable ball; @@ -47,12 +40,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Children = new[] { - followCircle = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()) + new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()) { Origin = Anchor.Centre, Anchor = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Alpha = 0, }, followCircleReceptor = new CircularContainer { @@ -103,10 +95,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables tracking = value; - followCircleReceptor.Scale = new Vector2(tracking ? 2.4f : 1f); - - followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); - followCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); + followCircleReceptor.Scale = new Vector2(tracking ? FOLLOW_AREA : 1f); } } @@ -189,17 +178,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private Vector2? lastPosition; + private bool rewinding; + public void UpdateProgress(double completionProgress) { Position = drawableSlider.HitObject.CurvePositionAt(completionProgress); var diff = lastPosition.HasValue ? lastPosition.Value - Position : Position - drawableSlider.HitObject.CurvePositionAt(completionProgress + 0.01f); + if (Clock.ElapsedFrameTime != 0) + rewinding = Clock.ElapsedFrameTime < 0; + // Ensure the value is substantially high enough to allow for Atan2 to get a valid angle. if (diff.LengthFast < 0.01f) return; - ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); + ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI) + (rewinding ? 180 : 0); lastPosition = Position; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 70b1bd225f..80b9544e5b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -63,7 +63,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables pathVersion.BindTo(DrawableSlider.PathVersion); - OnShake = DrawableSlider.Shake; CheckHittable = (d, t) => DrawableSlider.CheckHittable?.Invoke(d, t) ?? true; } @@ -96,9 +95,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return result.IsHit() ? HitResult.LargeTickHit : HitResult.LargeTickMiss; } - public Action OnShake; - - public override void Shake(double maximumLength) => OnShake?.Invoke(maximumLength); + public override void Shake() + { + base.Shake(); + DrawableSlider.Shake(); + } private void updatePosition() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 1bddc603ac..7b9c0c7e40 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - InternalChild = scaleContainer = new Container + AddInternal(scaleContainer = new Container { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }, Arrow = new ReverseArrowPiece(), } - }; + }); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index df3a12fe33..063d297f5a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { scaleContainer = new Container { @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) } }, - }; + }); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index 3ffbe68b98..4bd98fc8b2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); Origin = Anchor.Centre; - InternalChild = scaleContainer = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer + AddInternal(scaleContainer = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer { Masking = true, Origin = Anchor.Centre, @@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { Anchor = Anchor.Centre, Origin = Anchor.Centre, - }; + }); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 53f4d21975..6ae9d5bc34 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// public readonly IBindable SpinsPerMinute = new BindableDouble(); - private const double fade_out_duration = 160; + private const double fade_out_duration = 240; public DrawableSpinner() : this(null) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs index 6a15463a32..4975ca1248 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; } - protected override double MaximumJudgementOffset => DrawableSpinner.HitObject.Duration; + public override double MaximumJudgementOffset => DrawableSpinner.HitObject.Duration; /// /// Apply a judgement result. diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 387342b4a9..7b98fc48e0 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -7,12 +7,12 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; -using osuTK; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects { @@ -36,12 +36,14 @@ namespace osu.Game.Rulesets.Osu.Objects public double TimePreempt = 600; public double TimeFadeIn = 400; - public readonly Bindable PositionBindable = new Bindable(); + private HitObjectProperty position; + + public Bindable PositionBindable => position.Bindable; public virtual Vector2 Position { - get => PositionBindable.Value; - set => PositionBindable.Value = value; + get => position.Value; + set => position.Value = value; } public float X => Position.X; @@ -53,66 +55,80 @@ namespace osu.Game.Rulesets.Osu.Objects public Vector2 StackedEndPosition => EndPosition + StackOffset; - public readonly Bindable StackHeightBindable = new Bindable(); + private HitObjectProperty stackHeight; + + public Bindable StackHeightBindable => stackHeight.Bindable; public int StackHeight { - get => StackHeightBindable.Value; - set => StackHeightBindable.Value = value; + get => stackHeight.Value; + set => stackHeight.Value = value; } public virtual Vector2 StackOffset => new Vector2(StackHeight * Scale * -6.4f); public double Radius => OBJECT_RADIUS * Scale; - public readonly Bindable ScaleBindable = new BindableFloat(1); + private HitObjectProperty scale = new HitObjectProperty(1); + + public Bindable ScaleBindable => scale.Bindable; public float Scale { - get => ScaleBindable.Value; - set => ScaleBindable.Value = value; + get => scale.Value; + set => scale.Value = value; } public virtual bool NewCombo { get; set; } - public readonly Bindable ComboOffsetBindable = new Bindable(); + private HitObjectProperty comboOffset; + + public Bindable ComboOffsetBindable => comboOffset.Bindable; public int ComboOffset { - get => ComboOffsetBindable.Value; - set => ComboOffsetBindable.Value = value; + get => comboOffset.Value; + set => comboOffset.Value = value; } - public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); + private HitObjectProperty indexInCurrentCombo; + + public Bindable IndexInCurrentComboBindable => indexInCurrentCombo.Bindable; public virtual int IndexInCurrentCombo { - get => IndexInCurrentComboBindable.Value; - set => IndexInCurrentComboBindable.Value = value; + get => indexInCurrentCombo.Value; + set => indexInCurrentCombo.Value = value; } - public Bindable ComboIndexBindable { get; } = new Bindable(); + private HitObjectProperty comboIndex; + + public Bindable ComboIndexBindable => comboIndex.Bindable; public virtual int ComboIndex { - get => ComboIndexBindable.Value; - set => ComboIndexBindable.Value = value; + get => comboIndex.Value; + set => comboIndex.Value = value; } - public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + private HitObjectProperty comboIndexWithOffsets; + + public Bindable ComboIndexWithOffsetsBindable => comboIndexWithOffsets.Bindable; public int ComboIndexWithOffsets { - get => ComboIndexWithOffsetsBindable.Value; - set => ComboIndexWithOffsetsBindable.Value = value; + get => comboIndexWithOffsets.Value; + set => comboIndexWithOffsets.Value = value; } - public Bindable LastInComboBindable { get; } = new Bindable(); + private HitObjectProperty lastInCombo; + + public Bindable LastInComboBindable => lastInCombo.Bindable; public bool LastInCombo { - get => LastInComboBindable.Value; - set => LastInComboBindable.Value = value; + get => lastInCombo.Value; + set => lastInCombo.Value = value; } protected OsuHitObject() diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index a5468ff613..6c2be8a49a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -14,6 +14,7 @@ using osu.Framework.Caching; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; @@ -33,21 +34,6 @@ namespace osu.Game.Rulesets.Osu.Objects public override IList AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray(); - public IList CreateSlidingSamples() - { - var slidingSamples = new List(); - - var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); - if (normalSample != null) - slidingSamples.Add(normalSample.With("sliderslide")); - - var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); - if (whistleSample != null) - slidingSamples.Add(whistleSample.With("sliderwhistle")); - - return slidingSamples; - } - private readonly Cached endPositionCache = new Cached(); public override Vector2 EndPosition => endPositionCache.IsValid ? endPositionCache.Value : endPositionCache.Value = Position + this.CurvePositionAt(1); @@ -165,11 +151,15 @@ namespace osu.Game.Rulesets.Osu.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); +#pragma warning disable 618 + var legacyDifficultyPoint = DifficultyControlPoint as LegacyBeatmapDecoder.LegacyDifficultyControlPoint; +#pragma warning restore 618 double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; + bool generateTicks = legacyDifficultyPoint?.GenerateTicks ?? true; Velocity = scoringDistance / timingPoint.BeatLength; - TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; + TickDistance = generateTicks ? (scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier) : double.PositiveInfinity; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index 569e9b7c1c..676ff62455 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Objects // This is so on repeats ticks don't appear too late to be visually processed by the player. offset = 200; else - offset = TimeFadeIn * 0.66f; + offset = TimePreempt * 0.66f; TimePreempt = (StartTime - SpanStartTime) / 2 + offset; } diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index 12256e93d0..1e59e19246 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -5,10 +5,12 @@ using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Input.StateChanges.Events; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu @@ -17,9 +19,16 @@ namespace osu.Game.Rulesets.Osu { public IEnumerable PressedActions => KeyBindingContainer.PressedActions; - public bool AllowUserPresses + /// + /// Whether gameplay input buttons should be allowed. + /// Defaults to true, generally used for mods like Relax which turn off main inputs. + /// + /// + /// Of note, auxiliary inputs like the "smoke" key are left usable. + /// + public bool AllowGameplayInputs { - set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowUserPresses = value; + set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowGameplayInputs = value; } /// @@ -58,18 +67,36 @@ namespace osu.Game.Rulesets.Osu private class OsuKeyBindingContainer : RulesetKeyBindingContainer { - public bool AllowUserPresses = true; + private bool allowGameplayInputs = true; + + /// + /// Whether gameplay input buttons should be allowed. + /// Defaults to true, generally used for mods like Relax which turn off main inputs. + /// + /// + /// Of note, auxiliary inputs like the "smoke" key are left usable. + /// + public bool AllowGameplayInputs + { + get => allowGameplayInputs; + set + { + allowGameplayInputs = value; + ReloadMappings(); + } + } public OsuKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) : base(ruleset, variant, unique) { } - protected override bool Handle(UIEvent e) + protected override void ReloadMappings(IQueryable realmKeyBindings) { - if (!AllowUserPresses) return false; + base.ReloadMappings(realmKeyBindings); - return base.Handle(e); + if (!AllowGameplayInputs) + KeyBindings = KeyBindings.Where(b => b.GetAction() == OsuAction.Smoke).ToList(); } } } @@ -80,6 +107,9 @@ namespace osu.Game.Rulesets.Osu LeftButton, [Description("Right button")] - RightButton + RightButton, + + [Description("Smoke")] + Smoke, } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 120ce32612..e823053be9 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -1,49 +1,49 @@ // 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.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.UI; -using osu.Game.Rulesets.UI; +using System; using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Settings; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Osu.Edit; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Osu.Replays; -using osu.Game.Rulesets.Replays.Types; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Difficulty; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Skinning; -using System; -using System.Linq; -using osu.Framework.Extensions.EnumExtensions; +using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Setup; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Osu.Skinning.Argon; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu { public class OsuRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableOsuRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor(); @@ -53,10 +53,13 @@ namespace osu.Game.Rulesets.Osu public const string SHORT_NAME = "osu"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.Z, OsuAction.LeftButton), new KeyBinding(InputKey.X, OsuAction.RightButton), + new KeyBinding(InputKey.C, OsuAction.Smoke), new KeyBinding(InputKey.MouseLeft, OsuAction.LeftButton), new KeyBinding(InputKey.MouseRight, OsuAction.RightButton), }; @@ -172,7 +175,7 @@ namespace osu.Game.Rulesets.Osu new OsuModClassic(), new OsuModRandom(), new OsuModMirror(), - new OsuModAlternate(), + new MultiMod(new OsuModAlternate(), new OsuModSingleTap()) }; case ModType.Automation: @@ -197,7 +200,7 @@ namespace osu.Game.Rulesets.Osu new OsuModApproachDifferent(), new OsuModMuted(), new OsuModNoScope(), - new OsuModMagnetised(), + new MultiMod(new OsuModMagnetised(), new OsuModRepel()), new ModAdaptiveSpeed() }; @@ -230,13 +233,25 @@ namespace osu.Game.Rulesets.Osu public override RulesetSettingsSubsection CreateSettings() => new OsuSettingsSubsection(this); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new OsuLegacySkinTransformer(skin); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case LegacySkin: + return new OsuLegacySkinTransformer(skin); + + case ArgonSkin: + return new OsuArgonSkinTransformer(skin); + } + + return null; + } public int LegacyID => 0; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame(); - public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo); + public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new OsuRulesetConfigManager(settings, RulesetInfo); protected override IEnumerable GetValidHitResults() { @@ -253,7 +268,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/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index fcf079b6aa..4248cce55a 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Osu SliderBall, SliderBody, SpinnerBody, + CursorSmoke, ApproachCircle, } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs index 85060261fe..8082c5aef4 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs @@ -31,6 +31,7 @@ namespace osu.Game.Rulesets.Osu.Replays Position = currentFrame.Position; if (currentFrame.MouseLeft) Actions.Add(OsuAction.LeftButton); if (currentFrame.MouseRight) Actions.Add(OsuAction.RightButton); + if (currentFrame.Smoke) Actions.Add(OsuAction.Smoke); } public LegacyReplayFrame ToLegacy(IBeatmap beatmap) @@ -41,6 +42,8 @@ namespace osu.Game.Rulesets.Osu.Replays state |= ReplayButtonState.Left1; if (Actions.Contains(OsuAction.RightButton)) state |= ReplayButtonState.Right1; + if (Actions.Contains(OsuAction.Smoke)) + state |= ReplayButtonState.Smoke; return new LegacyReplayFrame(Time, Position.X, Position.Y, state); } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs index 05fbac625e..6f55e1790f 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -1,20 +1,23 @@ // 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.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { public class OsuHitWindows : HitWindows { + /// + /// osu! ruleset has a fixed miss window regardless of difficulty settings. + /// + public const double MISS_WINDOW = 400; + private static readonly DifficultyRange[] osu_ranges = { new DifficultyRange(HitResult.Great, 80, 50, 20), new DifficultyRange(HitResult.Ok, 140, 100, 60), new DifficultyRange(HitResult.Meh, 200, 150, 100), - new DifficultyRange(HitResult.Miss, 400, 400, 400), + new DifficultyRange(HitResult.Miss, MISS_WINDOW, MISS_WINDOW, MISS_WINDOW), }; public override bool IsHitResultAllowed(HitResult result) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursor.cs new file mode 100644 index 0000000000..446f3c83ae --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursor.cs @@ -0,0 +1,80 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonCursor : OsuCursorSprite + { + public ArgonCursor() + { + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new[] + { + ExpandTarget = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 6, + BorderColour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + Colour = Colour4.FromHex("FC618F").Darken(0.6f), + }, + new CircularContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 2, + BorderColour = Color4.White.Opacity(0.8f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }, + }, + }, + new Circle + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.2f), + Colour = new Color4(255, 255, 255, 255), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 20, + Colour = new Color4(171, 255, 255, 100), + }, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursorTrail.cs new file mode 100644 index 0000000000..9bb3122a3b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursorTrail.cs @@ -0,0 +1,29 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonCursorTrail : CursorTrail + { + protected override float IntervalMultiplier => 0.4f; + + protected override float FadeExponent => 4; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(@"Cursor/cursortrail"); + Scale = new Vector2(0.8f / Texture.ScaleAdjust); + + Blending = BlendingParameters.Additive; + + Alpha = 0.8f; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowCircle.cs new file mode 100644 index 0000000000..83c5f6295a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowCircle.cs @@ -0,0 +1,71 @@ +// 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.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonFollowCircle : FollowCircle + { + public ArgonFollowCircle() + { + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 4, + BorderColour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")), + Blending = BlendingParameters.Additive, + Child = new Box + { + Colour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")), + RelativeSizeAxes = Axes.Both, + Alpha = 0.3f, + } + }; + } + + protected override void OnSliderPress() + { + const float duration = 300f; + + if (Precision.AlmostEquals(0, Alpha)) + this.ScaleTo(1); + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA, duration, Easing.OutQuint) + .FadeIn(duration, Easing.OutQuint); + } + + protected override void OnSliderRelease() + { + const float duration = 150; + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.2f, duration, Easing.OutQuint) + .FadeTo(0, duration, Easing.OutQuint); + } + + protected override void OnSliderEnd() + { + const float duration = 300; + + this.ScaleTo(1, duration, Easing.OutQuint) + .FadeOut(duration / 2, Easing.OutQuint); + } + + protected override void OnSliderTick() + { + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.08f, 40, Easing.OutQuint) + .Then() + .ScaleTo(DrawableSliderBall.FOLLOW_AREA, 200f, Easing.OutQuint); + } + + protected override void OnSliderBreak() + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowPoint.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowPoint.cs new file mode 100644 index 0000000000..47dae3c30a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowPoint.cs @@ -0,0 +1,39 @@ +// 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.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonFollowPoint : CompositeDrawable + { + public ArgonFollowPoint() + { + Blending = BlendingParameters.Additive; + + Colour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")); + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(8), + Colour = OsuColour.Gray(0.2f), + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(8), + X = 4, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 0000000000..bb68c7298f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,172 @@ +// 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.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonJudgementPiece : CompositeDrawable, IAnimatableJudgement + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } = null!; + + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + { + Result = result; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + JudgementText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = Result.GetDescription().ToUpperInvariant(), + Colour = colours.ForHitResult(Result), + Blending = BlendingParameters.Additive, + Spacing = new Vector2(5, 0), + Font = OsuFont.Default.With(size: 20, weight: FontWeight.Bold), + }, + }; + + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + }); + } + } + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + switch (Result) + { + default: + JudgementText + .FadeInFromZero(300, Easing.OutQuint) + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.2f), 1800, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + break; + } + + this.FadeOutFromOne(800); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => JudgementText.CreateProxy(); + + private class RingExplosion : CompositeDrawable + { + private readonly float travel = 52; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.3f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs new file mode 100644 index 0000000000..36dc8c801d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs @@ -0,0 +1,227 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonMainCirclePiece : CompositeDrawable + { + public const float BORDER_THICKNESS = (OsuHitObject.OBJECT_RADIUS * 2) * (2f / 58); + + public const float GRADIENT_THICKNESS = BORDER_THICKNESS * 2.5f; + + public const float OUTER_GRADIENT_SIZE = (OsuHitObject.OBJECT_RADIUS * 2) - BORDER_THICKNESS * 4; + + public const float INNER_GRADIENT_SIZE = OUTER_GRADIENT_SIZE - GRADIENT_THICKNESS * 2; + public const float INNER_FILL_SIZE = INNER_GRADIENT_SIZE - GRADIENT_THICKNESS * 2; + + private readonly Circle outerFill; + private readonly Circle outerGradient; + private readonly Circle innerGradient; + private readonly Circle innerFill; + + private readonly RingPiece border; + private readonly OsuSpriteText number; + + private readonly IBindable accentColour = new Bindable(); + private readonly IBindable indexInCurrentCombo = new Bindable(); + private readonly FlashPiece flash; + + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; + + public ArgonMainCirclePiece(bool withOuterFill) + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + outerFill = new Circle // renders white outer border and dark fill + { + Size = Size, + Alpha = withOuterFill ? 1 : 0, + }, + outerGradient = new Circle // renders the outer bright gradient + { + Size = new Vector2(OUTER_GRADIENT_SIZE), + Alpha = 1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + innerGradient = new Circle // renders the inner bright gradient + { + Size = new Vector2(INNER_GRADIENT_SIZE), + Alpha = 1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + innerFill = new Circle // renders the inner dark fill + { + Size = new Vector2(INNER_FILL_SIZE), + Alpha = 1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + number = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 52, weight: FontWeight.Bold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -2, + Text = @"1", + }, + flash = new FlashPiece(), + border = new RingPiece(BORDER_THICKNESS), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + var drawableOsuObject = (DrawableOsuHitObject)drawableObject; + + accentColour.BindTo(drawableObject.AccentColour); + indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + + accentColour.BindValueChanged(colour => + { + // A colour transform is applied. + // Without removing transforms first, when it is rewound it may apply an old colour. + outerGradient.ClearTransforms(targetMember: nameof(Colour)); + outerGradient.Colour = ColourInfo.GradientVertical(colour.NewValue, colour.NewValue.Darken(0.1f)); + + outerFill.Colour = innerFill.Colour = colour.NewValue.Darken(4); + innerGradient.Colour = ColourInfo.GradientVertical(colour.NewValue.Darken(0.5f), colour.NewValue.Darken(0.6f)); + flash.Colour = colour.NewValue; + + updateStateTransforms(drawableObject, drawableObject.State.Value); + }, true); + + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + switch (state) + { + case ArmedState.Hit: + // Fade out time is at a maximum of 800. Must match `DrawableHitCircle`'s arbitrary lifetime spec. + const double fade_out_time = 800; + + const double flash_in_duration = 150; + const double resize_duration = 400; + + const float shrink_size = 0.8f; + + // Animating with the number present is distracting. + // The number disappearing is hidden by the bright flash. + number.FadeOut(flash_in_duration / 2); + + // The fill layers add too much noise during the explosion animation. + // They will be hidden by the additive effects anyway. + outerFill.FadeOut(flash_in_duration, Easing.OutQuint); + innerFill.FadeOut(flash_in_duration, Easing.OutQuint); + + // The inner-most gradient should actually be resizing, but is only visible for + // a few milliseconds before it's hidden by the flash, so it's pointless overhead to bother with it. + innerGradient.FadeOut(flash_in_duration, Easing.OutQuint); + + // The border is always white, but after hit it gets coloured by the skin/beatmap's colouring. + // A gradient is applied to make the border less prominent over the course of the animation. + // Without this, the border dominates the visual presence of the explosion animation in a bad way. + border.TransformTo(nameof + (BorderColour), ColourInfo.GradientVertical( + accentColour.Value.Opacity(0.5f), + accentColour.Value.Opacity(0)), fade_out_time); + + // The outer ring shrinks immediately, but accounts for its thickness so it doesn't overlap the inner + // gradient layers. + border.ResizeTo(Size * shrink_size + new Vector2(border.BorderThickness), resize_duration, Easing.OutElasticHalf); + + // The outer gradient is resize with a slight delay from the border. + // This is to give it a bomb-like effect, with the border "triggering" its animation when getting close. + using (BeginDelayedSequence(flash_in_duration / 12)) + { + outerGradient.ResizeTo(outerGradient.Size * shrink_size, resize_duration, Easing.OutElasticHalf); + outerGradient + .FadeColour(Color4.White, 80) + .Then() + .FadeOut(flash_in_duration); + } + + flash.FadeTo(1, flash_in_duration, Easing.OutQuint); + + this.FadeOut(fade_out_time, Easing.OutQuad); + break; + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject.IsNotNull()) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } + + private class FlashPiece : Circle + { + public FlashPiece() + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Alpha = 0; + Blending = BlendingParameters.Additive; + + // The edge effect provides the fill due to not being rendered hollow. + Child.Alpha = 0; + Child.AlwaysPresent = true; + } + + protected override void Update() + { + base.Update(); + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Colour, + Radius = OsuHitObject.OBJECT_RADIUS * 1.2f, + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs new file mode 100644 index 0000000000..9d44db3614 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs @@ -0,0 +1,54 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonReverseArrow : CompositeDrawable + { + private Bindable accentColour = null!; + + private SpriteIcon icon = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + InternalChildren = new Drawable[] + { + new Circle + { + Size = new Vector2(40, 20), + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + icon = new SpriteIcon + { + Icon = FontAwesome.Solid.AngleDoubleRight, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + + accentColour = hitObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs new file mode 100644 index 0000000000..3df9edd225 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs @@ -0,0 +1,109 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonSliderBall : CircularContainer + { + private readonly Box fill; + private readonly SpriteIcon icon; + + private readonly Vector2 defaultIconScale = new Vector2(0.6f, 0.8f); + + [Resolved(canBeNull: true)] + private DrawableHitObject? parentObject { get; set; } + + public ArgonSliderBall() + { + Size = new Vector2(ArgonMainCirclePiece.OUTER_GRADIENT_SIZE); + + Masking = true; + + BorderThickness = ArgonMainCirclePiece.GRADIENT_THICKNESS; + BorderColour = Color4.White; + + InternalChildren = new Drawable[] + { + fill = new Box + { + Colour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")), + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + icon = new SpriteIcon + { + Size = new Vector2(48), + Scale = defaultIconScale, + Icon = FontAwesome.Solid.AngleRight, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (parentObject != null) + { + parentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(parentObject, parentObject.State.Value); + } + } + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState _) + { + // Gets called by slider ticks, tails, etc., leading to duplicated + // animations which in this case have no visual impact (due to + // instant fade) but may negatively affect performance + if (drawableObject is not DrawableSlider) + return; + + const float duration = 200; + const float icon_scale = 0.9f; + + using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) + { + this.FadeInFromZero(duration, Easing.OutQuint); + icon.ScaleTo(0).Then().ScaleTo(defaultIconScale, duration, Easing.OutElasticHalf); + } + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + this.FadeOut(duration, Easing.OutQuint); + icon.ScaleTo(defaultIconScale * icon_scale, duration, Easing.OutQuint); + } + } + + protected override void Update() + { + base.Update(); + + //undo rotation on layers which should not be rotated. + float appliedRotation = Parent.Rotation; + + fill.Rotation = -appliedRotation; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (parentObject != null) + parentObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs new file mode 100644 index 0000000000..e1642d126d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs @@ -0,0 +1,40 @@ +// 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.Extensions.Color4Extensions; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonSliderBody : PlaySliderBody + { + protected override void LoadComplete() + { + const float path_radius = ArgonMainCirclePiece.OUTER_GRADIENT_SIZE / 2; + + base.LoadComplete(); + + AccentColourBindable.BindValueChanged(accent => BorderColour = accent.NewValue, true); + ScaleBindable.BindValueChanged(scale => PathRadius = path_radius * scale.NewValue, true); + + // This border size thing is kind of weird, hey. + const float intended_thickness = ArgonMainCirclePiece.GRADIENT_THICKNESS / path_radius; + + BorderSize = intended_thickness / Default.DrawableSliderPath.BORDER_PORTION; + } + + protected override Default.DrawableSliderPath CreateSliderPath() => new DrawableSliderPath(); + + private class DrawableSliderPath : Default.DrawableSliderPath + { + protected override Color4 ColourAt(float position) + { + if (CalculatedBorderPortion != 0f && position <= CalculatedBorderPortion) + return BorderColour; + + return AccentColour.Darken(4); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderScorePoint.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderScorePoint.cs new file mode 100644 index 0000000000..4c6b9a2f17 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderScorePoint.cs @@ -0,0 +1,40 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonSliderScorePoint : CircularContainer + { + private Bindable accentColour = null!; + + private const float size = 12; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject) + { + Masking = true; + Origin = Anchor.Centre; + Size = new Vector2(size); + BorderThickness = 3; + BorderColour = Color4.White; + Child = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + }; + + accentColour = hitObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(accent => BorderColour = accent.NewValue, true); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs new file mode 100644 index 0000000000..95438e9588 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs @@ -0,0 +1,146 @@ +// 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.Globalization; +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.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonSpinner : CompositeDrawable + { + private DrawableSpinner drawableSpinner = null!; + + private OsuSpriteText bonusCounter = null!; + + private Container spmContainer = null!; + private OsuSpriteText spmCounter = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + drawableSpinner = (DrawableSpinner)drawableHitObject; + + InternalChildren = new Drawable[] + { + bonusCounter = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 24), + Y = -120, + }, + new ArgonSpinnerDisc + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + bonusCounter = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 28, weight: FontWeight.Bold), + Y = -100, + }, + spmContainer = new Container + { + Alpha = 0f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 60, + Children = new[] + { + spmCounter = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"0", + Font = OsuFont.Default.With(size: 28, weight: FontWeight.SemiBold) + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"SPINS PER MINUTE", + Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), + Y = 30 + } + } + } + }; + } + + private IBindable gainedBonus = null!; + private IBindable spinsPerMinute = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); + gainedBonus.BindValueChanged(bonus => + { + bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); + bonusCounter.FadeOutFromOne(1500); + bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); + }); + + spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); + spinsPerMinute.BindValueChanged(spm => + { + spmCounter.Text = Math.Truncate(spm.NewValue).ToString(@"#0"); + }, true); + + drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); + } + + protected override void Update() + { + base.Update(); + + if (!spmContainer.IsPresent && drawableSpinner.Result?.TimeStarted != null) + fadeCounterOnTimeStart(); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSpinner)) + return; + + fadeCounterOnTimeStart(); + } + + private void fadeCounterOnTimeStart() + { + if (drawableSpinner.Result?.TimeStarted is double startTime) + { + using (BeginAbsoluteSequence(startTime)) + spmContainer.FadeIn(drawableSpinner.HitObject.TimeFadeIn); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner.IsNotNull()) + drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs new file mode 100644 index 0000000000..4669b5b913 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs @@ -0,0 +1,247 @@ +// 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.Diagnostics; +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.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonSpinnerDisc : CompositeDrawable + { + private const float initial_scale = 1f; + private const float idle_alpha = 0.2f; + private const float tracking_alpha = 0.4f; + + private const float idle_centre_size = 80f; + private const float tracking_centre_size = 40f; + + private DrawableSpinner drawableSpinner = null!; + + private readonly BindableBool complete = new BindableBool(); + + private int wholeRotationCount; + + private bool checkNewRotationCount + { + get + { + int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); + + if (wholeRotationCount == rotations) return false; + + wholeRotationCount = rotations; + return true; + } + } + + private Container disc = null!; + private Container centre = null!; + private CircularContainer fill = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + drawableSpinner = (DrawableSpinner)drawableHitObject; + + // we are slightly bigger than our parent, to clip the top and bottom of the circle + // this should probably be revisited when scaled spinners are a thing. + Scale = new Vector2(initial_scale); + + InternalChildren = new Drawable[] + { + disc = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + fill = new CircularContainer + { + Name = @"Fill", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Colour4.FromHex("FC618F").Opacity(1f), + Radius = 40, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0f, + AlwaysPresent = true, + } + }, + new CircularContainer + { + Name = @"Ring", + Masking = true, + BorderColour = Color4.White, + BorderThickness = 5, + RelativeSizeAxes = Axes.Both, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, + new ArgonSpinnerTicks(), + } + }, + centre = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(idle_centre_size), + Children = new[] + { + new RingPiece(10) + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + }, + new RingPiece(3) + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1f), + } + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; + + updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); + } + + protected override void Update() + { + base.Update(); + + complete.Value = Time.Current >= drawableSpinner.Result.TimeCompleted; + + if (complete.Value) + { + if (checkNewRotationCount) + { + fill.FinishTransforms(false, nameof(Alpha)); + fill + .FadeTo(tracking_alpha + 0.2f, 60, Easing.OutExpo) + .Then() + .FadeTo(tracking_alpha, 250, Easing.OutQuint); + } + } + else + { + fill.Alpha = (float)Interpolation.Damp(fill.Alpha, drawableSpinner.RotationTracker.Tracking ? tracking_alpha : idle_alpha, 0.98f, (float)Math.Abs(Clock.ElapsedFrameTime)); + } + + if (centre.Width == idle_centre_size && drawableSpinner.Result?.TimeStarted != null) + updateCentrePieceSize(); + + const float initial_fill_scale = 0.1f; + float targetScale = initial_fill_scale + (0.98f - initial_fill_scale) * drawableSpinner.Progress; + + fill.Scale = new Vector2((float)Interpolation.Lerp(fill.Scale.X, targetScale, Math.Clamp(Math.Abs(Time.Elapsed) / 100, 0, 1))); + disc.Rotation = drawableSpinner.RotationTracker.Rotation; + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSpinner)) + return; + + Spinner spinner = drawableSpinner.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) + { + this.ScaleTo(initial_scale); + this.RotateTo(0); + + using (BeginDelayedSequence(spinner.TimePreempt / 2)) + { + // constant ambient rotation to give the spinner "spinning" character. + this.RotateTo((float)(25 * spinner.Duration / 2000), spinner.TimePreempt + spinner.Duration); + } + + using (BeginDelayedSequence(spinner.TimePreempt + spinner.Duration + drawableHitObject.Result.TimeOffset)) + { + switch (state) + { + case ArmedState.Hit: + this.ScaleTo(initial_scale * 1.2f, 320, Easing.Out); + this.RotateTo(Rotation + 180, 320); + break; + + case ArmedState.Miss: + this.ScaleTo(initial_scale * 0.8f, 320, Easing.In); + break; + } + } + } + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) + { + centre.ScaleTo(0); + disc.ScaleTo(0); + + using (BeginDelayedSequence(spinner.TimePreempt / 2)) + { + centre.ScaleTo(0.3f, spinner.TimePreempt / 4, Easing.OutQuint); + disc.ScaleTo(0.2f, spinner.TimePreempt / 4, Easing.OutQuint); + + using (BeginDelayedSequence(spinner.TimePreempt / 2)) + { + centre.ScaleTo(0.8f, spinner.TimePreempt / 2, Easing.OutQuint); + disc.ScaleTo(1, spinner.TimePreempt / 2, Easing.OutQuint); + } + } + } + + if (drawableSpinner.Result?.TimeStarted != null) + updateCentrePieceSize(); + } + + private void updateCentrePieceSize() + { + Debug.Assert(drawableSpinner.Result?.TimeStarted != null); + + Spinner spinner = drawableSpinner.HitObject; + + using (BeginAbsoluteSequence(drawableSpinner.Result.TimeStarted.Value)) + centre.ResizeTo(new Vector2(tracking_centre_size), spinner.TimePreempt / 2, Easing.OutQuint); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner.IsNotNull()) + drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerTicks.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerTicks.cs new file mode 100644 index 0000000000..0203432088 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerTicks.cs @@ -0,0 +1,61 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class ArgonSpinnerTicks : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; + Anchor = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + + const float count = 25; + + for (float i = 0; i < count; i++) + { + AddInternal(new CircularContainer + { + RelativePositionAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + BorderColour = Color4.White, + BorderThickness = 2f, + Size = new Vector2(30, 5), + Origin = Anchor.Centre, + Position = new Vector2( + 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.75f, + 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.75f + ), + Rotation = -i / count * 360 - 120, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Colour4.White.Opacity(0.2f), + Radius = 30, + }, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs new file mode 100644 index 0000000000..bf507db50c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs @@ -0,0 +1,68 @@ +// 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.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class OsuArgonSkinTransformer : SkinTransformer + { + public OsuArgonSkinTransformer(ISkin skin) + : base(skin) + { + } + + public override Drawable? GetDrawableComponent(ISkinComponent component) + { + switch (component) + { + case GameplaySkinComponent resultComponent: + return new ArgonJudgementPiece(resultComponent.Component); + + case OsuSkinComponent osuComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (osuComponent.Component) + { + case OsuSkinComponents.HitCircle: + return new ArgonMainCirclePiece(true); + + case OsuSkinComponents.SliderHeadHitCircle: + return new ArgonMainCirclePiece(false); + + case OsuSkinComponents.SliderBody: + return new ArgonSliderBody(); + + case OsuSkinComponents.SliderBall: + return new ArgonSliderBall(); + + case OsuSkinComponents.SliderFollowCircle: + return new ArgonFollowCircle(); + + case OsuSkinComponents.SliderScorePoint: + return new ArgonSliderScorePoint(); + + case OsuSkinComponents.SpinnerBody: + return new ArgonSpinner(); + + case OsuSkinComponents.ReverseArrow: + return new ArgonReverseArrow(); + + case OsuSkinComponents.FollowPoint: + return new ArgonFollowPoint(); + + case OsuSkinComponents.Cursor: + return new ArgonCursor(); + + case OsuSkinComponents.CursorTrail: + return new ArgonCursorTrail(); + } + + break; + } + + return base.GetDrawableComponent(component); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs index 8211448705..aaace89cd5 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs @@ -4,16 +4,16 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Objects.Drawables; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class DefaultFollowCircle : CompositeDrawable + public class DefaultFollowCircle : FollowCircle { public DefaultFollowCircle() { - RelativeSizeAxes = Axes.Both; - InternalChild = new CircularContainer { RelativeSizeAxes = Axes.Both, @@ -29,5 +29,43 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } }; } + + protected override void OnSliderPress() + { + const float duration = 300f; + + if (Precision.AlmostEquals(0, Alpha)) + this.ScaleTo(1); + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA, duration, Easing.OutQuint) + .FadeIn(duration, Easing.OutQuint); + } + + protected override void OnSliderRelease() + { + const float duration = 150; + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.2f, duration, Easing.OutQuint) + .FadeTo(0, duration, Easing.OutQuint); + } + + protected override void OnSliderEnd() + { + const float duration = 300; + + this.ScaleTo(1, duration, Easing.OutQuint) + .FadeOut(duration / 2, Easing.OutQuint); + } + + protected override void OnSliderTick() + { + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.08f, 40, Easing.OutQuint) + .Then() + .ScaleTo(DrawableSliderBall.FOLLOW_AREA, 200f, Easing.OutQuint); + } + + protected override void OnSliderBreak() + { + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.cs index 47308375e6..97bb4a3697 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.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.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -19,13 +17,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { public class DefaultSliderBall : CompositeDrawable { - private Box box; + private Box box = null!; + + [Resolved(canBeNull: true)] + private DrawableHitObject? parentObject { get; set; } [BackgroundDependencyLoader] - private void load(DrawableHitObject drawableObject, ISkinSource skin) + private void load(ISkinSource skin) { - var slider = (DrawableSlider)drawableObject; - RelativeSizeAxes = Axes.Both; float radius = skin.GetConfig(OsuSkinConfiguration.SliderPathRadius)?.Value ?? OsuHitObject.OBJECT_RADIUS; @@ -51,10 +50,62 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } }; - slider.Tracking.BindValueChanged(trackingChanged, true); + if (parentObject != null) + { + var slider = (DrawableSlider)parentObject; + slider.Tracking.BindValueChanged(trackingChanged, true); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (parentObject != null) + { + parentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(parentObject, parentObject.State.Value); + } } private void trackingChanged(ValueChangedEvent tracking) => box.FadeTo(tracking.NewValue ? 0.3f : 0.05f, 200, Easing.OutQuint); + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState state) + { + // Gets called by slider ticks, tails, etc., leading to duplicated + // animations which may negatively affect performance + if (drawableObject is not DrawableSlider) + return; + + const float fade_duration = 450f; + + using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) + { + this.FadeIn() + .ScaleTo(1f); + } + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + // intentionally pile on an extra FadeOut to make it happen much faster + this.FadeOut(fade_duration / 4, Easing.Out); + + switch (state) + { + case ArmedState.Hit: + this.ScaleTo(1.4f, fade_duration, Easing.Out); + break; + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (parentObject != null) + parentObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs new file mode 100644 index 0000000000..27a2dc3960 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs @@ -0,0 +1,19 @@ +// 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.Allocation; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public class DefaultSmokeSegment : SmokeSegment + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + // ISkinSource doesn't currently fallback to global textures. + // We might want to change this in the future if the intention is to allow the user to skin this as per legacy skins. + Texture = textures.Get("Gameplay/osu/cursor-smoke"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs index 94f93807d4..e3a83a9280 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs @@ -10,8 +10,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { public abstract class DrawableSliderPath : SmoothPath { - protected const float BORDER_PORTION = 0.128f; - protected const float GRADIENT_PORTION = 1 - BORDER_PORTION; + public const float BORDER_PORTION = 0.128f; + public const float GRADIENT_PORTION = 1 - BORDER_PORTION; private const float border_max_size = 8f; private const float border_min_size = 0f; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs index 83f7bb8904..6c422cf127 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs @@ -16,9 +16,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { public abstract class PlaySliderBody : SnakingSliderBody { - private IBindable scaleBindable; + protected IBindable ScaleBindable { get; private set; } = null!; + + protected IBindable AccentColourBindable { get; private set; } = null!; + private IBindable pathVersion; - private IBindable accentColour; [Resolved(CanBeNull = true)] private OsuRulesetConfigManager config { get; set; } @@ -30,14 +32,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { var drawableSlider = (DrawableSlider)drawableObject; - scaleBindable = drawableSlider.ScaleBindable.GetBoundCopy(); - scaleBindable.BindValueChanged(scale => PathRadius = OsuHitObject.OBJECT_RADIUS * scale.NewValue, true); + ScaleBindable = drawableSlider.ScaleBindable.GetBoundCopy(); + ScaleBindable.BindValueChanged(scale => PathRadius = OsuHitObject.OBJECT_RADIUS * scale.NewValue, true); pathVersion = drawableSlider.PathVersion.GetBoundCopy(); pathVersion.BindValueChanged(_ => Refresh()); - accentColour = drawableObject.AccentColour.GetBoundCopy(); - accentColour.BindValueChanged(accent => AccentColour = GetBodyAccentColour(skin, accent.NewValue), true); + AccentColourBindable = drawableObject.AccentColour.GetBoundCopy(); + AccentColourBindable.BindValueChanged(accent => AccentColour = GetBodyAccentColour(skin, accent.NewValue), true); config?.BindWith(OsuRulesetSetting.SnakingInSliders, SnakingIn); config?.BindWith(OsuRulesetSetting.SnakingOutSliders, configSnakingOut); diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs index b941a86171..e813a7e274 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.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.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { public class RingPiece : CircularContainer { - public RingPiece() + public RingPiece(float thickness = 9) { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); @@ -22,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Origin = Anchor.Centre; Masking = true; - BorderThickness = 9; // roughly matches slider borders and makes stacked circles distinctly visible from each other. + BorderThickness = thickness; BorderColour = Color4.White; Child = new Box diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index f9ed8b8721..97cebc3123 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private bool rotationTransferred; [Resolved(canBeNull: true)] - private GameplayClock gameplayClock { get; set; } + private IGameplayClock gameplayClock { get; set; } protected override void Update() { @@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default currentRotation += angle; // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback // (see: ModTimeRamp) - drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.TrueGameplayRate ?? Clock.Rate)); + drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate)); } private void resetState(DrawableHitObject obj) diff --git a/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs new file mode 100644 index 0000000000..9eb8e66c83 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public abstract class FollowCircle : CompositeDrawable + { + [Resolved] + protected DrawableHitObject? ParentObject { get; private set; } + + protected FollowCircle() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + ((DrawableSlider?)ParentObject)?.Tracking.BindValueChanged(tracking => + { + Debug.Assert(ParentObject != null); + if (ParentObject.Judged) + return; + + if (tracking.NewValue) + OnSliderPress(); + else + OnSliderRelease(); + }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (ParentObject != null) + { + ParentObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(ParentObject); + + ParentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(ParentObject, ParentObject.State.Value); + } + } + + private void onHitObjectApplied(DrawableHitObject drawableObject) + { + this.ScaleTo(1f) + .FadeOut(); + } + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState state) + { + Debug.Assert(ParentObject != null); + + switch (state) + { + case ArmedState.Hit: + switch (drawableObject) + { + case DrawableSliderTail: + // Use ParentObject instead of drawableObject because slider tail's + // HitStateUpdateTime is ~36ms before the actual slider end (aka slider + // tail leniency) + using (BeginAbsoluteSequence(ParentObject.HitStateUpdateTime)) + OnSliderEnd(); + break; + + case DrawableSliderTick: + case DrawableSliderRepeat: + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + OnSliderTick(); + break; + } + + break; + + case ArmedState.Miss: + switch (drawableObject) + { + case DrawableSliderTail: + case DrawableSliderTick: + case DrawableSliderRepeat: + // Despite above comment, ok to use drawableObject.HitStateUpdateTime + // here, since on stable, the break anim plays right when the tail is + // missed, not when the slider ends + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + OnSliderBreak(); + break; + } + + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (ParentObject != null) + { + ParentObject.HitObjectApplied -= onHitObjectApplied; + ParentObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } + + protected abstract void OnSliderPress(); + + protected abstract void OnSliderRelease(); + + protected abstract void OnSliderEnd(); + + protected abstract void OnSliderTick(); + + protected abstract void OnSliderBreak(); + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs index b8a559ce07..0d12fb01f5 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.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 System; +using System.Diagnostics; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyFollowCircle : CompositeDrawable + public class LegacyFollowCircle : FollowCircle { public LegacyFollowCircle(Drawable animationContent) { @@ -18,5 +19,39 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy RelativeSizeAxes = Axes.Both; InternalChild = animationContent; } + + protected override void OnSliderPress() + { + Debug.Assert(ParentObject != null); + + double remainingTime = Math.Max(0, ParentObject.HitStateUpdateTime - Time.Current); + + // Note that the scale adjust here is 2 instead of DrawableSliderBall.FOLLOW_AREA to match legacy behaviour. + // This means the actual tracking area for gameplay purposes is larger than the sprite (but skins may be accounting for this). + this.ScaleTo(0.5f).ScaleTo(2f, Math.Min(180f, remainingTime), Easing.Out) + .FadeTo(0).FadeTo(1f, Math.Min(60f, remainingTime)); + } + + protected override void OnSliderRelease() + { + } + + protected override void OnSliderEnd() + { + this.ScaleTo(1.6f, 200, Easing.Out) + .FadeOut(200, Easing.In); + } + + protected override void OnSliderTick() + { + this.ScaleTo(2.2f) + .ScaleTo(2f, 200); + } + + protected override void OnSliderBreak() + { + this.ScaleTo(4f, 100) + .FadeTo(0f, 100); + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 1b2ab82044..a6e62b83e4 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy InternalChildren = new[] { - CircleSprite = new KiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) + CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = OverlaySprite = new KiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) + Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -134,10 +134,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (state) { case ArmedState.Hit: - CircleSprite.FadeOut(legacy_fade_duration, Easing.Out); + CircleSprite.FadeOut(legacy_fade_duration); CircleSprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); - OverlaySprite.FadeOut(legacy_fade_duration, Easing.Out); + OverlaySprite.FadeOut(legacy_fade_duration); OverlaySprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); if (hasNumber) @@ -146,11 +146,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (legacyVersion >= 2.0m) // legacy skins of version 2.0 and newer only apply very short fade out to the number piece. - hitCircleText.FadeOut(legacy_fade_duration / 4, Easing.Out); + hitCircleText.FadeOut(legacy_fade_duration / 4); else { // old skins scale and fade it normally along other pieces. - hitCircleText.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleText.FadeOut(legacy_fade_duration); hitCircleText.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index d5cc469ca9..71c3e4c9f0 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy var topProvider = source.FindProvider(s => s.GetTexture("spinner-top") != null); - if (topProvider is LegacySkinTransformer transformer && !(transformer.Skin is DefaultLegacySkin)) + if (topProvider is ISkinTransformer transformer && !(transformer.Skin is DefaultLegacySkin)) { AddInternal(ApproachCircle = new Sprite { @@ -107,8 +107,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) this.FadeOut(); - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2)) - this.FadeInFromZero(spinner.TimeFadeIn / 2); + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn)) + this.FadeInFromZero(spinner.TimeFadeIn); using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) { diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs index 414879f42d..60d71ae843 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -21,6 +22,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy [Resolved(canBeNull: true)] private DrawableHitObject? parentObject { get; set; } + public Color4 BallColour => animationContent.Colour; + private Sprite layerNd = null!; private Sprite layerSpec = null!; @@ -61,6 +64,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy }; } + private readonly IBindable accentColour = new Bindable(); + protected override void LoadComplete() { base.LoadComplete(); @@ -69,6 +74,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { parentObject.ApplyCustomUpdateState += updateStateTransforms; updateStateTransforms(parentObject, parentObject.State.Value); + + if (skin.GetConfig(SkinConfiguration.LegacySetting.AllowSliderBallTint)?.Value == true) + { + accentColour.BindTo(parentObject.AccentColour); + accentColour.BindValueChanged(a => animationContent.Colour = a.NewValue, true); + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs new file mode 100644 index 0000000000..c9c7e86e86 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs @@ -0,0 +1,19 @@ +// 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.Allocation; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public class LegacySmokeSegment : SmokeSegment + { + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + base.LoadComplete(); + + Texture = skin.GetTexture("cursor-smoke"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index 004222ad7a..a817e5f2b7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -65,6 +65,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { spin = new Sprite { + Alpha = 0, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Texture = source.GetTexture("spinner-spin"), @@ -82,7 +83,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy }, bonusCounter = new LegacySpriteText(LegacyFont.Score) { - Alpha = 0f, + Alpha = 0, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Scale = new Vector2(SPRITE_SCALE), @@ -179,6 +180,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out); } + using (BeginAbsoluteSequence(d.HitObject.StartTime - d.HitObject.TimeFadeIn / 2)) + spin.FadeInFromZero(d.HitObject.TimeFadeIn / 2); + using (BeginAbsoluteSequence(d.HitObject.StartTime)) ApproachCircle?.ScaleTo(SPRITE_SCALE * 0.1f, d.HitObject.Duration); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 885a2c12fb..856ccb5044 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -13,6 +13,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class OsuLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasHitCircle.Value; + private readonly Lazy hasHitCircle; /// @@ -106,6 +108,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return null; + case OsuSkinComponents.CursorSmoke: + if (GetTexture("cursor-smoke") != null) + return new LegacySmokeSegment(); + + return null; + case OsuSkinComponents.HitCircleText: if (!this.HasFont(LegacyFont.HitCircle)) return null; diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 306a1e38b9..1c0a62454b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -9,7 +9,6 @@ namespace osu.Game.Rulesets.Osu.Skinning { SliderBorderSize, SliderPathRadius, - AllowSliderBallTint, CursorCentre, CursorExpand, CursorRotate, diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs new file mode 100644 index 0000000000..46c8e7c02a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -0,0 +1,380 @@ +// 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.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public abstract class SmokeSegment : Drawable, ITexturedShaderDrawable + { + // fade anim values + private const double initial_fade_out_duration = 4000; + + private const double re_fade_in_speed = 3; + private const double re_fade_in_duration = 50; + + private const double final_fade_out_speed = 2; + private const double final_fade_out_duration = 8000; + + private const float initial_alpha = 0.6f; + private const float re_fade_in_alpha = 1f; + + private readonly int rotationSeed = RNG.Next(); + + // scale anim values + private const double scale_duration = 1200; + + private const float initial_scale = 0.65f; + private const float final_scale = 1f; + + // rotation anim values + private const double rotation_duration = 500; + + private const float max_rotation = 0.25f; + + public IShader? TextureShader { get; private set; } + public IShader? RoundedTextureShader { get; private set; } + + protected Texture? Texture { get; set; } + + private float radius => Texture?.DisplayWidth * 0.165f ?? 3; + + protected readonly List SmokePoints = new List(); + + private float pointInterval => radius * 7f / 8; + + private double smokeStartTime { get; set; } = double.MinValue; + + private double smokeEndTime { get; set; } = double.MaxValue; + + private float totalDistance; + private Vector2? lastPosition; + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RelativeSizeAxes = Axes.Both; + + LifetimeStart = smokeStartTime = Time.Current; + + totalDistance = pointInterval; + } + + public void AddPosition(Vector2 position, double time) + { + lastPosition ??= position; + + float delta = (position - (Vector2)lastPosition).LengthFast; + totalDistance += delta; + int count = (int)(totalDistance / pointInterval); + + if (count > 0) + { + Vector2 increment = position - (Vector2)lastPosition; + increment.NormalizeFast(); + + Vector2 pointPos = (pointInterval - (totalDistance - delta)) * increment + (Vector2)lastPosition; + increment *= pointInterval; + + totalDistance %= pointInterval; + + if (SmokePoints.Count == 0 || SmokePoints[^1].Time <= time) + { + for (int i = 0; i < count; i++) + { + SmokePoints.Add(new SmokePoint + { + Position = pointPos, + Time = time, + Angle = RNG.NextSingle(0, 2 * MathF.PI), + }); + + pointPos += increment; + } + } + + Invalidate(Invalidation.DrawNode); + } + + lastPosition = position; + } + + public void FinishDrawing(double time) + { + smokeEndTime = time; + + double initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, smokeEndTime - smokeStartTime); + LifetimeEnd = smokeEndTime + final_fade_out_duration + initialFadeOutDurationTrunc / re_fade_in_speed + initialFadeOutDurationTrunc / final_fade_out_speed; + } + + protected override DrawNode CreateDrawNode() => new SmokeDrawNode(this); + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + } + + protected struct SmokePoint + { + public Vector2 Position; + public double Time; + public float Angle; + + public struct UpperBoundComparer : IComparer + { + public int Compare(SmokePoint x, SmokePoint target) + { + // By returning -1 when the target value is equal to x, guarantees that the + // element at BinarySearch's returned index will always be the first element + // larger. Since 0 is never returned, the target is never "found", so the return + // value will be the index's complement. + + return x.Time > target.Time ? 1 : -1; + } + } + + public struct LowerBoundComparer : IComparer + { + public int Compare(SmokePoint x, SmokePoint target) + { + // Similar logic as UpperBoundComparer, except returned index will always be + // the first element larger or equal + + return x.Time < target.Time ? -1 : 1; + } + } + } + + protected class SmokeDrawNode : TexturedShaderDrawNode + { + protected new SmokeSegment Source => (SmokeSegment)base.Source; + + protected double SmokeStartTime { get; private set; } + protected double SmokeEndTime { get; private set; } + protected double CurrentTime { get; private set; } + + private readonly List points = new List(); + private IVertexBatch? quadBatch; + private float radius; + private Vector2 drawSize; + private Texture? texture; + private int rotationSeed; + private int firstVisiblePointIndex; + + // anim calculation vars (color, scale, direction) + private double initialFadeOutDurationTrunc; + private double firstVisiblePointTimeAfterSmokeEnded; + + private double initialFadeOutTime; + private double reFadeInTime; + private double finalFadeOutTime; + + public SmokeDrawNode(ITexturedShaderDrawable source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + radius = Source.radius; + drawSize = Source.DrawSize; + texture = Source.Texture; + + SmokeStartTime = Source.smokeStartTime; + SmokeEndTime = Source.smokeEndTime; + CurrentTime = Source.Clock.CurrentTime; + + rotationSeed = Source.rotationSeed; + + initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, SmokeEndTime - SmokeStartTime); + firstVisiblePointTimeAfterSmokeEnded = SmokeEndTime - initialFadeOutDurationTrunc; + + initialFadeOutTime = Math.Min(CurrentTime, SmokeEndTime); + reFadeInTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / re_fade_in_speed); + finalFadeOutTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / final_fade_out_speed); + + double firstVisiblePointTime = Math.Min(SmokeEndTime, CurrentTime) - initialFadeOutDurationTrunc; + firstVisiblePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = firstVisiblePointTime }, new SmokePoint.LowerBoundComparer()); + int futurePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = CurrentTime }, new SmokePoint.UpperBoundComparer()); + + points.Clear(); + points.AddRange(Source.SmokePoints.Skip(firstVisiblePointIndex).Take(futurePointIndex - firstVisiblePointIndex)); + } + + public sealed override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (points.Count == 0) + return; + + quadBatch ??= renderer.CreateQuadBatch(200, 4); + + if (points.Count > quadBatch.Size && quadBatch.Size != IRenderer.MAX_QUADS) + { + int batchSize = Math.Min(quadBatch.Size * 2, IRenderer.MAX_QUADS); + quadBatch = renderer.CreateQuadBatch(batchSize, 4); + } + + texture ??= renderer.WhitePixel; + RectangleF textureRect = texture.GetTextureRect(); + + var shader = GetAppropriateShader(renderer); + + renderer.SetBlend(BlendingParameters.Additive); + renderer.PushLocalMatrix(DrawInfo.Matrix); + + shader.Bind(); + texture.Bind(); + + for (int i = 0; i < points.Count; i++) + drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex); + + shader.Unbind(); + renderer.PopLocalMatrix(); + } + + protected Color4 ColourAtPosition(Vector2 localPos) => DrawColourInfo.Colour.HasSingleColour + ? ((SRGBColour)DrawColourInfo.Colour).Linear + : DrawColourInfo.Colour.Interpolate(Vector2.Divide(localPos, drawSize)).Linear; + + protected virtual Color4 PointColour(SmokePoint point) + { + var color = Color4.White; + + double timeDoingFinalFadeOut = finalFadeOutTime - point.Time / final_fade_out_speed; + + if (timeDoingFinalFadeOut > 0 && point.Time >= firstVisiblePointTimeAfterSmokeEnded) + { + float fraction = Math.Clamp((float)(timeDoingFinalFadeOut / final_fade_out_duration), 0, 1); + fraction = MathF.Pow(fraction, 5); + color.A = (1 - fraction) * re_fade_in_alpha; + } + else + { + double timeDoingInitialFadeOut = initialFadeOutTime - point.Time; + + if (timeDoingInitialFadeOut > 0) + { + float fraction = Math.Clamp((float)(timeDoingInitialFadeOut / initial_fade_out_duration), 0, 1); + color.A = (1 - fraction) * initial_alpha; + } + + if (point.Time > firstVisiblePointTimeAfterSmokeEnded) + { + double timeDoingReFadeIn = reFadeInTime - point.Time / re_fade_in_speed; + + if (timeDoingReFadeIn > 0) + { + float fraction = Math.Clamp((float)(timeDoingReFadeIn / re_fade_in_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + color.A = fraction * (re_fade_in_alpha - color.A) + color.A; + } + } + } + + return color; + } + + protected virtual float PointScale(SmokePoint point) + { + double timeDoingScale = CurrentTime - point.Time; + float fraction = Math.Clamp((float)(timeDoingScale / scale_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + return fraction * (final_scale - initial_scale) + initial_scale; + } + + protected virtual Vector2 PointDirection(SmokePoint point, int index) + { + double timeDoingRotation = CurrentTime - point.Time; + float fraction = Math.Clamp((float)(timeDoingRotation / rotation_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + float angle = fraction * getRotation(index) + point.Angle; + + return new Vector2(MathF.Sin(angle), -MathF.Cos(angle)); + } + + private float getRotation(int index) => max_rotation * (StatelessRNG.NextSingle(rotationSeed, index) * 2 - 1); + + private void drawPointQuad(SmokePoint point, RectangleF textureRect, int index) + { + Debug.Assert(quadBatch != null); + + var colour = PointColour(point); + if (colour.A == 0) + return; + + float scale = PointScale(point); + if (scale == 0) + return; + + var dir = PointDirection(point, index); + var ortho = dir.PerpendicularLeft; + + var localTopLeft = point.Position + (radius * scale * (-ortho - dir)); + var localTopRight = point.Position + (radius * scale * (-ortho + dir)); + var localBotLeft = point.Position + (radius * scale * (ortho - dir)); + var localBotRight = point.Position + (radius * scale * (ortho + dir)); + + quadBatch.Add(new TexturedVertex2D + { + Position = localTopLeft, + TexturePosition = textureRect.TopLeft, + Colour = Color4Extensions.Multiply(ColourAtPosition(localTopLeft), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localTopRight, + TexturePosition = textureRect.TopRight, + Colour = Color4Extensions.Multiply(ColourAtPosition(localTopRight), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localBotRight, + TexturePosition = textureRect.BottomRight, + Colour = Color4Extensions.Multiply(ColourAtPosition(localBotRight), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localBotLeft, + TexturePosition = textureRect.BottomLeft, + Colour = Color4Extensions.Multiply(ColourAtPosition(localBotLeft), colour), + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + quadBatch?.Dispose(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index c1d518a843..1b2ef23674 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -9,9 +9,9 @@ using System.Runtime.InteropServices; using osu.Framework.Allocation; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.Input; @@ -68,8 +68,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } [BackgroundDependencyLoader] - private void load(ShaderManager shaders) + private void load(IRenderer renderer, ShaderManager shaders) { + texture ??= renderer.WhitePixel; shader = shaders.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE); } @@ -79,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor resetTime(); } - private Texture texture = Texture.WhitePixel; + private Texture texture; public Texture Texture { @@ -222,7 +223,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private Vector2 size; private Vector2 originPosition; - private readonly QuadBatch vertexBatch = new QuadBatch(max_sprites, 1); + private IVertexBatch vertexBatch; public TrailDrawNode(CursorTrail source) : base(source) @@ -254,15 +255,17 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor Source.parts.CopyTo(parts, 0); } - public override void Draw(Action vertexAction) + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); + + vertexBatch ??= renderer.CreateQuadBatch(max_sprites, 1); shader.Bind(); shader.GetUniform("g_FadeClock").UpdateValue(ref time); shader.GetUniform("g_FadeExponent").UpdateValue(ref fadeExponent); - texture.TextureGL.Bind(); + texture.Bind(); RectangleF textureRect = texture.GetTextureRect(); @@ -319,7 +322,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { base.Dispose(isDisposing); - vertexBatch.Dispose(); + vertexBatch?.Dispose(); } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 3179b37d5a..e9a6c84c0b 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -11,9 +11,11 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -34,6 +36,7 @@ namespace osu.Game.Rulesets.Osu.UI private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; + public SmokeContainer Smoke { get; } public FollowPointRenderer FollowPoints { get; } public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); @@ -52,6 +55,7 @@ namespace osu.Game.Rulesets.Osu.UI InternalChildren = new Drawable[] { playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + Smoke = new SmokeContainer { RelativeSizeAxes = Axes.Both }, spinnerProxies = new ProxyContainer { RelativeSizeAxes = Axes.Both }, FollowPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }, judgementLayer = new JudgementContainer { RelativeSizeAxes = Axes.Both }, @@ -112,21 +116,36 @@ namespace osu.Game.Rulesets.Osu.UI } [BackgroundDependencyLoader(true)] - private void load(OsuRulesetConfigManager config) + private void load(OsuRulesetConfigManager config, IBeatmap beatmap) { config?.BindWith(OsuRulesetSetting.PlayfieldBorderStyle, playfieldBorder.PlayfieldBorderStyle); - RegisterPool(10, 100); + var osuBeatmap = (OsuBeatmap)beatmap; - RegisterPool(10, 100); - RegisterPool(10, 100); - RegisterPool(10, 100); - RegisterPool(10, 100); - RegisterPool(5, 50); + RegisterPool(20, 100); + + // handle edge cases where a beatmap has a slider with many repeats. + int maxRepeatsOnOneSlider = 0; + int maxTicksOnOneSlider = 0; + + if (osuBeatmap != null) + { + foreach (var slider in osuBeatmap.HitObjects.OfType()) + { + maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount); + maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType().Count()); + } + } + + RegisterPool(20, 100); + RegisterPool(20, 100); + RegisterPool(20, 100); + RegisterPool(Math.Max(maxTicksOnOneSlider, 20), Math.Max(maxTicksOnOneSlider, 200)); + RegisterPool(Math.Max(maxRepeatsOnOneSlider, 20), Math.Max(maxRepeatsOnOneSlider, 200)); RegisterPool(2, 20); - RegisterPool(10, 100); - RegisterPool(10, 100); + RegisterPool(10, 200); + RegisterPool(10, 200); } protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new OsuHitObjectLifetimeEntry(hitObject); @@ -173,7 +192,7 @@ namespace osu.Game.Rulesets.Osu.UI private readonly Action onLoaded; public DrawableJudgementPool(HitResult result, Action onLoaded) - : base(10) + : base(20) { this.result = result; this.onLoaded = onLoaded; 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.Osu/UI/SmokeContainer.cs b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs new file mode 100644 index 0000000000..beba834e88 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs @@ -0,0 +1,77 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Manages smoke trails generated from user input. + /// + public class SmokeContainer : Container, IRequireHighFrequencyMousePosition, IKeyBindingHandler + { + private SmokeSkinnableDrawable? currentSegmentSkinnable; + + private Vector2 lastMousePosition; + + public override bool ReceivePositionalInputAt(Vector2 _) => true; + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == OsuAction.Smoke) + { + AddInternal(currentSegmentSkinnable = new SmokeSkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment())); + + // Add initial position immediately. + addPosition(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == OsuAction.Smoke) + { + if (currentSegmentSkinnable?.Drawable is SmokeSegment segment) + { + segment.FinishDrawing(Time.Current); + currentSegmentSkinnable = null; + } + } + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + lastMousePosition = e.MousePosition; + addPosition(); + + return base.OnMouseMove(e); + } + + private void addPosition() => (currentSegmentSkinnable?.Drawable as SmokeSegment)?.AddPosition(lastMousePosition, Time.Current); + + private class SmokeSkinnableDrawable : SkinnableDrawable + { + public override bool RemoveWhenNotAlive => true; + + public override double LifetimeStart => Drawable.LifetimeStart; + public override double LifetimeEnd => Drawable.LifetimeEnd; + + public SmokeSkinnableDrawable(ISkinComponent component, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(component, defaultImplementation, confineMode) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index 5e827d4782..3a8b3f67d0 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -186,5 +187,39 @@ namespace osu.Game.Rulesets.Osu.Utils length * MathF.Sin(angle) ); } + + /// The beatmap hitObject is a part of. + /// The that should be checked. + /// If true, this method only returns true if hitObject is on a downbeat. + /// If false, it returns true if hitObject is on any beat. + /// true if hitObject is on a (down-)beat, false otherwise. + public static bool IsHitObjectOnBeat(OsuBeatmap beatmap, OsuHitObject hitObject, bool downbeatsOnly = false) + { + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); + + double timeSinceTimingPoint = hitObject.StartTime - timingPoint.Time; + + double beatLength = timingPoint.BeatLength; + + if (downbeatsOnly) + beatLength *= timingPoint.TimeSignature.Numerator; + + // Ensure within 1ms of expected location. + return Math.Abs(timeSinceTimingPoint + 1) % beatLength < 2; + } + + /// + /// Generates a random number from a normal distribution using the Box-Muller transform. + /// + public static float RandomGaussian(Random rng, float mean = 0, float stdDev = 1) + { + // Generate 2 random numbers in the interval (0,1]. + // x1 must not be 0 since log(0) = undefined. + double x1 = 1 - rng.NextDouble(); + double x2 = 1 - rng.NextDouble(); + + double stdNormal = Math.Sqrt(-2 * Math.Log(x1)) * Math.Sin(2 * Math.PI * x2); + return mean + stdDev * (float)stdNormal; + } } } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs index 81830ca633..a9ae313a31 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -194,7 +194,7 @@ namespace osu.Game.Rulesets.Osu.Utils private static Vector2 clampSliderToPlayfield(WorkingObject workingObject) { var slider = (Slider)workingObject.HitObject; - var possibleMovementBounds = calculatePossibleMovementBounds(slider); + var possibleMovementBounds = CalculatePossibleMovementBounds(slider); // The slider rotation applied in computeModifiedPosition might make it impossible to fit the slider into the playfield // For example, a long horizontal slider will be off-screen when rotated by 90 degrees @@ -214,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Utils RotateSlider(slider, workingObject.RotationOriginal + MathF.PI - getSliderRotation(slider)); } - possibleMovementBounds = calculatePossibleMovementBounds(slider); + possibleMovementBounds = CalculatePossibleMovementBounds(slider); } var previousPosition = workingObject.PositionModified; @@ -260,10 +260,12 @@ namespace osu.Game.Rulesets.Osu.Utils /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) /// such that the entire slider is inside the playfield. /// + /// The for which to calculate a movement bounding box. + /// A which contains all of the possible movements of the slider such that the entire slider is inside the playfield. /// /// If the slider is larger than the playfield, the returned may have negative width/height. /// - private static RectangleF calculatePossibleMovementBounds(Slider slider) + public static RectangleF CalculatePossibleMovementBounds(Slider slider) { var pathPositions = new List(); slider.Path.GetPathToProgress(pathPositions, 0, 1); diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs index a9b7e04caf..01719bfea6 100644 --- a/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs @@ -23,13 +23,29 @@ namespace osu.Game.Rulesets.Taiko.Tests protected DrawableTaikoRuleset DrawableRuleset { get; private set; } protected Container PlayfieldContainer { get; private set; } + private ControlPointInfo controlPointInfo { get; set; } + [BackgroundDependencyLoader] private void load() { - var controlPointInfo = new ControlPointInfo(); + controlPointInfo = new ControlPointInfo(); controlPointInfo.Add(0, new TimingControlPoint()); - IWorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap + IWorkingBeatmap beatmap = CreateWorkingBeatmap(CreateBeatmap(new TaikoRuleset().RulesetInfo)); + + Add(PlayfieldContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = DEFAULT_PLAYFIELD_CONTAINER_HEIGHT, + Children = new[] { DrawableRuleset = new DrawableTaikoRuleset(new TaikoRuleset(), beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset)) } + }); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + return new Beatmap { HitObjects = new List { new Hit { Type = HitType.Centre } }, BeatmapInfo = new BeatmapInfo @@ -41,19 +57,10 @@ namespace osu.Game.Rulesets.Taiko.Tests Title = @"Sample Beatmap", Author = { Username = @"peppy" }, }, - Ruleset = new TaikoRuleset().RulesetInfo + Ruleset = ruleset }, ControlPointInfo = controlPointInfo - }); - - Add(PlayfieldContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = DEFAULT_PLAYFIELD_CONTAINER_HEIGHT, - Children = new[] { DrawableRuleset = new DrawableTaikoRuleset(new TaikoRuleset(), beatmap.GetPlayableBeatmap(new TaikoRuleset().RulesetInfo)) } - }); + }; } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs index 16f17f4131..8d17918a92 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Editor public void Setup() => Schedule(() => { BeatDivisor.Value = 8; - Clock.Seek(0); + EditorClock.Seek(0); Child = new TestComposer { RelativeSizeAxes = Axes.Both }; }); diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs new file mode 100644 index 0000000000..7f2f27b2b8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs @@ -0,0 +1,96 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public class JudgementTest : RateAdjustedBeatmapTestScene + { + private ScoreAccessibleReplayPlayer currentPlayer = null!; + protected List JudgementResults { get; private set; } = null!; + + protected void AssertJudgementCount(int count) + { + AddAssert($"{count} judgement{(count > 0 ? "s" : "")}", () => JudgementResults, () => Has.Count.EqualTo(count)); + } + + protected void AssertResult(int index, HitResult expectedResult) + { + AddAssert($"{typeof(T).ReadableName()} ({index}) judged as {expectedResult}", + () => JudgementResults.Where(j => j.HitObject is T).OrderBy(j => j.HitObject.StartTime).ElementAt(index).Type, + () => Is.EqualTo(expectedResult)); + } + + protected void PerformTest(List frames, Beatmap? beatmap = null) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) JudgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + JudgementResults = new List(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + } + + protected Beatmap CreateBeatmap(params TaikoHitObject[] hitObjects) + { + var beatmap = new Beatmap + { + HitObjects = hitObjects.ToList(), + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); + return beatmap; + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs new file mode 100644 index 0000000000..2c28c3dad5 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs @@ -0,0 +1,201 @@ +// 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 NUnit.Framework; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public class TestSceneDrumRollJudgements : JudgementTest + { + [Test] + public void TestHitAllDrumRoll() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1001), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000 + })); + + AssertJudgementCount(3); + AssertResult(0, HitResult.SmallBonus); + AssertResult(1, HitResult.SmallBonus); + AssertResult(0, HitResult.IgnoreHit); + } + + [Test] + public void TestHitSomeDrumRoll() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000 + })); + + AssertJudgementCount(3); + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(1, HitResult.SmallBonus); + AssertResult(0, HitResult.IgnoreHit); + } + + [Test] + public void TestHitNoneDrumRoll() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000 + })); + + AssertJudgementCount(3); + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(1, HitResult.IgnoreMiss); + AssertResult(0, HitResult.IgnoreHit); + } + + [Test] + public void TestHitAllStrongDrumRollWithOneKey() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1001), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.SmallBonus); + AssertResult(0, HitResult.LargeBonus); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + + [Test] + public void TestHitSomeStrongDrumRollWithOneKey() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(0, HitResult.IgnoreMiss); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + + [Test] + public void TestHitAllStrongDrumRollWithBothKeys() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(1000, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(1001), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.SmallBonus); + AssertResult(0, HitResult.LargeBonus); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + + [Test] + public void TestHitSomeStrongDrumRollWithBothKeys() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(0, HitResult.IgnoreMiss); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs new file mode 100644 index 0000000000..d2d5cdb6ac --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs @@ -0,0 +1,161 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public class TestSceneHitJudgements : JudgementTest + { + [Test] + public void TestHitCentreHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftCentre), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time + })); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Great); + } + + [Test] + public void TestHitRimHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftRim), + }, CreateBeatmap(new Hit + { + Type = HitType.Rim, + StartTime = hit_time + })); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Great); + } + + [Test] + public void TestMissHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0) + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time + })); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Miss); + } + + [Test] + public void TestHitStrongHitWithOneKey() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftCentre), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + IsStrong = true + })); + + AssertJudgementCount(2); + AssertResult(0, HitResult.Great); + AssertResult(0, HitResult.IgnoreMiss); + } + + [Test] + public void TestHitStrongHitWithBothKeys() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftCentre, TaikoAction.RightCentre), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + IsStrong = true + })); + + AssertJudgementCount(2); + AssertResult(0, HitResult.Great); + AssertResult(0, HitResult.LargeBonus); + } + + [Test] + public void TestMissStrongHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + IsStrong = true + })); + + AssertJudgementCount(2); + AssertResult(0, HitResult.Miss); + AssertResult(0, HitResult.IgnoreMiss); + } + + [Test] + public void TestHighVelocityHit() + { + const double hit_time = 1000; + + var beatmap = CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + }); + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 6 }); + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 10 }); + + var hitWindows = new HitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time - hitWindows.WindowFor(HitResult.Great), TaikoAction.LeftCentre), + }, beatmap); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Ok); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs new file mode 100644 index 0000000000..7bdfcf0b07 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs @@ -0,0 +1,118 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public class TestSceneSwellJudgements : JudgementTest + { + [Test] + public void TestHitAllSwell() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + for (int i = 0; i < swell.RequiredHits; i++) + { + double frameTime = 1000 + i * 50; + frames.Add(new TaikoReplayFrame(frameTime, i % 2 == 0 ? TaikoAction.LeftCentre : TaikoAction.LeftRim)); + frames.Add(new TaikoReplayFrame(frameTime + 10)); + } + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + for (int i = 0; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreHit); + + AssertResult(0, HitResult.LargeBonus); + } + + [Test] + public void TestHitSomeSwell() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + for (int i = 0; i < swell.RequiredHits / 2; i++) + { + double frameTime = 1000 + i * 50; + frames.Add(new TaikoReplayFrame(frameTime, i % 2 == 0 ? TaikoAction.LeftCentre : TaikoAction.LeftRim)); + frames.Add(new TaikoReplayFrame(frameTime + 10)); + } + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + for (int i = 0; i < swell.RequiredHits / 2; i++) + AssertResult(i, HitResult.IgnoreHit); + for (int i = swell.RequiredHits / 2; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreMiss); + + AssertResult(0, HitResult.IgnoreMiss); + } + + [Test] + public void TestHitNoneSwell() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + for (int i = 0; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreMiss); + + AssertResult(0, HitResult.IgnoreMiss); + + AddAssert("all tick offsets are 0", () => JudgementResults.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs index ca2f8102b7..3090facf8c 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.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.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Mods diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs new file mode 100644 index 0000000000..417b59f5d2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Taiko.Mods; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public class TestSceneTaikoModFlashlight : TaikoModTestScene + { + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.25f)] + [TestCase(1.5f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new TaikoModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new TaikoModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs index 0b28bfee2e..7abbb9d186 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.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.Game.Rulesets.Taiko.Mods; diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs index 917462c128..92503a9f03 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.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 NUnit.Framework; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Mods; @@ -27,11 +25,11 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods [TestCase(false)] [TestCase(true)] - public void TestDrumRoll(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new DrumRoll { StartTime = 1000, EndTime = 3000 }), shouldMiss); + public void TestDrumRoll(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new DrumRoll { StartTime = 1000, EndTime = 3000 }, false), shouldMiss); [TestCase(false)] [TestCase(true)] - public void TestSwell(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Swell { StartTime = 1000, EndTime = 3000 }), shouldMiss); + public void TestSwell(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Swell { StartTime = 1000, EndTime = 3000 }, false), shouldMiss); private class TestTaikoRuleset : TaikoRuleset { diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs index 2d27e0e40e..e42dc254ac 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs @@ -25,8 +25,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning TimeRange = { Value = 5000 }, }; - [BackgroundDependencyLoader] - private void load() + [Test] + public void DrumrollTest() { AddStep("Drum roll", () => SetContents(_ => { diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRollKiai.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRollKiai.cs new file mode 100644 index 0000000000..53977150e7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRollKiai.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableDrumRollKiai : TestSceneDrawableDrumRoll + { + [SetUp] + public void SetUp() => Schedule(() => + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + // track needs to be playing for BeatSyncedContainer to work. + Beatmap.Value.Track.Start(); + }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs index d5a97f8f88..8e9c487c2f 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs @@ -4,7 +4,6 @@ #nullable disable using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -16,8 +15,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestFixture] public class TestSceneDrawableHit : TaikoSkinnableTestScene { - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestHits() { AddStep("Centre hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime()) { diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHitKiai.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHitKiai.cs new file mode 100644 index 0000000000..fac0530749 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHitKiai.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableHitKiai : TestSceneDrawableHit + { + [SetUp] + public void SetUp() => Schedule(() => + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + // track needs to be playing for BeatSyncedContainer to work. + Beatmap.Value.Track.Start(); + }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index ef95358d34..71ce4c08d0 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,13 +108,10 @@ 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); - assertStateAfterResult(new JudgementResult(new DrumRoll(), new TaikoDrumRollJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Idle); } @@ -126,9 +119,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 +130,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 +164,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 +195,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/Skinning/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index ca3e12cfd5..f342bfe78e 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200), - Child = new InputDrum(playfield.HitObjectContainer) + Child = new InputDrum() } }); } 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.Tests/TestSceneBarLineGeneration.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs new file mode 100644 index 0000000000..095fddc33f --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneBarLineGeneration : OsuTestScene + { + [Test] + public void TestCloseBarLineGeneration() + { + const double start_time = 1000; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = start_time + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(start_time, new TimingControlPoint()); + beatmap.ControlPointInfo.Add(start_time + 1, new TimingControlPoint()); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("first barline generated", () => barlines.Any(b => b.StartTime == start_time)); + AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + 1)); + } + + [Test] + public void TestOmitBarLineEffectPoint() + { + const double start_time = 1000; + const double beat_length = 500; + + const int time_signature_numerator = 4; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = start_time + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(start_time, new TimingControlPoint + { + BeatLength = beat_length, + TimeSignature = new TimeSignature(time_signature_numerator) + }); + + beatmap.ControlPointInfo.Add(start_time, new EffectControlPoint { OmitFirstBarLine = true }); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("first barline ommited", () => barlines.All(b => b.StartTime != start_time)); + AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + (beat_length * time_signature_numerator))); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollJudgements.cs deleted file mode 100644 index 82dfaecaa4..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollJudgements.cs +++ /dev/null @@ -1,38 +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.Linq; -using NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Taiko.Objects; - -namespace osu.Game.Rulesets.Taiko.Tests -{ - public class TestSceneDrumRollJudgements : TestSceneTaikoPlayer - { - [Test] - public void TestStrongDrumRollFullyJudgedOnKilled() - { - AddUntilStep("gameplay finished", () => Player.ScoreProcessor.HasCompleted.Value); - AddAssert("all judgements are misses", () => Player.Results.All(r => r.Type == r.Judgement.MinResult)); - } - - protected override bool Autoplay => false; - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap - { - BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo }, - HitObjects = - { - new DrumRoll - { - StartTime = 1000, - Duration = 1000, - IsStrong = true - } - } - }; - } -} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumTouchInputArea.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumTouchInputArea.cs new file mode 100644 index 0000000000..7210419c0e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumTouchInputArea.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [TestFixture] + public class TestSceneDrumTouchInputArea : OsuTestScene + { + private DrumTouchInputArea drumTouchInputArea = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create drum", () => + { + Child = new TaikoInputManager(new TaikoRuleset().RulesetInfo) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new InputDrum + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Height = 0.2f, + }, + drumTouchInputArea = new DrumTouchInputArea + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + }, + }; + }); + } + + [Test] + public void TestDrum() + { + AddStep("show drum", () => drumTouchInputArea.Show()); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs deleted file mode 100644 index bd546b16f2..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs +++ /dev/null @@ -1,42 +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.Linq; -using NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Taiko.Objects; - -namespace osu.Game.Rulesets.Taiko.Tests -{ - public class TestSceneSwellJudgements : TestSceneTaikoPlayer - { - [Test] - public void TestZeroTickTimeOffsets() - { - AddUntilStep("gameplay finished", () => Player.ScoreProcessor.HasCompleted.Value); - AddAssert("all tick offsets are 0", () => Player.Results.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0)); - } - - protected override bool Autoplay => true; - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) - { - var beatmap = new Beatmap - { - BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo }, - HitObjects = - { - new Swell - { - StartTime = 1000, - Duration = 1000, - } - } - }; - - return beatmap; - } - } -} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs index 674ac5670f..c674f87f80 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.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.Reflection; using NUnit.Framework; using osu.Framework.IO.Stores; @@ -17,7 +15,6 @@ namespace osu.Game.Rulesets.Taiko.Tests protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); [TestCase("taiko-normal-hitnormal")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestDefaultCustomSampleFromBeatmap(string expectedSample) { @@ -29,7 +26,6 @@ namespace osu.Game.Rulesets.Taiko.Tests } [TestCase("taiko-normal-hitnormal")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) { @@ -41,7 +37,6 @@ namespace osu.Game.Rulesets.Taiko.Tests } [TestCase("taiko-normal-hitnormal2")] - [TestCase("normal-hitnormal2")] public void TestUserSkinLookupIgnoresSampleBank(string unwantedSample) { SetupSkins(string.Empty, unwantedSample); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerLegacySkin.cs similarity index 52% rename from osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs rename to osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerLegacySkin.cs index e0d1646cb0..13df24c988 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerLegacySkin.cs @@ -1,20 +1,18 @@ // 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.Mods; -using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Osu.Tests +namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneOsuFlashlight : TestSceneOsuPlayer + public class TestSceneTaikoPlayerLegacySkin : LegacySkinPlayerTestScene { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + protected override TestPlayer CreatePlayer(Ruleset ruleset) { - SelectedMods.Value = new Mod[] { new OsuModAutoplay(), new OsuModFlashlight(), }; - + SelectedMods.Value = new[] { new TaikoModClassic() }; return base.CreatePlayer(ruleset); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs index cdfab4a215..2169ac5581 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests }; [Test] - public void TestSpinnerDoesFail() + public void TestSwellDoesNotFail() { bool judged = false; AddStep("Setup judgements", () => @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Player.ScoreProcessor.NewJudgement += _ => judged = true; }); AddUntilStep("swell judged", () => judged); - AddAssert("failed", () => Player.GameplayState.HasFailed); + AddAssert("not failed", () => !Player.GameplayState.HasFailed); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index ce468d399b..38e61f5624 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -1,11 +1,9 @@  - - + - - + WinExe 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 228856cbe9..2b0b563323 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,21 +23,22 @@ 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 => 20220902; public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { } - 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[] { @@ -48,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) @@ -67,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); @@ -99,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 a9cde62f44..dc7bad2f75 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -20,6 +20,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private int countOk; private int countMeh; private int countMiss; + private double accuracy; + + private double effectiveMissCount; public TaikoPerformanceCalculator() : base(new TaikoRuleset()) @@ -34,14 +37,19 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + accuracy = customAccuracy; - double multiplier = 1.1; // 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; - if (score.Mods.Any(m => m is ModNoFail)) - multiplier *= 0.90; + double multiplier = 1.13; if (score.Mods.Any(m => m is ModHidden)) - multiplier *= 1.10; + multiplier *= 1.075; + + if (score.Mods.Any(m => m is ModEasy)) + multiplier *= 0.975; double difficultyValue = computeDifficultyValue(score, taikoAttributes); double accuracyValue = computeAccuracyValue(score, taikoAttributes); @@ -55,26 +63,33 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { Difficulty = difficultyValue, Accuracy = accuracyValue, + EffectiveMissCount = effectiveMissCount, Total = totalValue }; } private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) { - double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.175) - 4.0, 2.25) / 450.0; + double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); difficultyValue *= lengthBonus; - difficultyValue *= Math.Pow(0.985, countMiss); + difficultyValue *= Math.Pow(0.986, effectiveMissCount); + + if (score.Mods.Any(m => m is ModEasy)) + 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 * score.Accuracy; + if (score.Mods.Any(m => m is ModFlashlight)) + difficultyValue *= 1.050 * lengthBonus; + + return difficultyValue * Math.Pow(accuracy, 2.0); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) @@ -82,12 +97,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (attributes.GreatHitWindow <= 0) return 0; - double accValue = Math.Pow(150.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 15) * 22.0; + double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; - // Bonus for many objects - it's harder to keep good accuracy up for longer - return accValue * Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + accuracyValue *= lengthBonus; + + // 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 *= Math.Max(1.050, 1.075 * lengthBonus); + + return accuracyValue; } private int totalHits => countGreat + countOk + countMeh + countMiss; + + private int totalSuccessfulHits => countGreat + countOk + countMeh; + + private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index df1450bf77..863a2c9eac 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -27,6 +27,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints }; } + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + protected override bool OnMouseDown(MouseDownEvent e) { switch (e.Button) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index e49043e58e..70364cabf1 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -52,6 +52,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints private double originalStartTime; private Vector2 originalPosition; + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button != MouseButton.Left) @@ -67,7 +73,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/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs index 1c1a5c325f..161799c980 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.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 osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs deleted file mode 100644 index be128d85b5..0000000000 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs +++ /dev/null @@ -1,25 +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.Scoring; - -namespace osu.Game.Rulesets.Taiko.Judgements -{ - public class TaikoDrumRollJudgement : TaikoJudgement - { - protected override double HealthIncreaseFor(HitResult result) - { - // Drum rolls can be ignored with no health penalty - switch (result) - { - case HitResult.Miss: - return 0; - - default: - return base.HealthIncreaseFor(result); - } - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs index 5f2587a5d5..de56c76f56 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs @@ -9,18 +9,8 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoDrumRollTickJudgement : TaikoJudgement { - public override HitResult MaxResult => HitResult.SmallTickHit; + public override HitResult MaxResult => HitResult.SmallBonus; - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - case HitResult.SmallTickHit: - return 0.15; - - default: - return 0; - } - } + protected override double HealthIncreaseFor(HitResult result) => 0; } } diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs index f2397aba22..bafe7dfbaf 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoStrongJudgement : TaikoJudgement { - public override HitResult MaxResult => HitResult.SmallBonus; + public override HitResult MaxResult => HitResult.LargeBonus; // MainObject already changes the HP protected override double HealthIncreaseFor(HitResult result) => 0; diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs index b2ac0b7f03..146621997d 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs @@ -9,11 +9,13 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoSwellJudgement : TaikoJudgement { + public override HitResult MaxResult => HitResult.LargeBonus; + protected override double HealthIncreaseFor(HitResult result) { switch (result) { - case HitResult.Miss: + case HitResult.IgnoreMiss: return -0.65; default: diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 6c01bae027..4b74b4991e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.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 osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs index d1c192f7fa..fee0cb2744 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.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 osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs index 6d1a18bb78..f7fdd447d6 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -1,8 +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 - +using System.Diagnostics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -12,16 +11,21 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModClassic : ModClassic, IApplicableToDrawableRuleset, IUpdatableByPlayfield { - private DrawableTaikoRuleset drawableTaikoRuleset; + private DrawableTaikoRuleset? drawableTaikoRuleset; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; drawableTaikoRuleset.LockPlayfieldAspect.Value = false; + + var playfield = (TaikoPlayfield)drawableRuleset.Playfield; + playfield.ClassicHitTargetPosition.Value = true; } public void Update(Playfield playfield) { + Debug.Assert(drawableTaikoRuleset != null); + // Classic taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened. const float scroll_rate = 10; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs index 873aa7f992..84aa5e6bba 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 564d023c5a..99a064d35f 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.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 osu.Game.Beatmaps; using osu.Game.Configuration; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs index 01f1632ae2..89581c57bd 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs @@ -1,14 +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 osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs index a37e1c6f5c..009f2854f8 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs @@ -1,8 +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 - +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -10,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/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index 2f9cccfe86..98f954ad29 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -1,12 +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 osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Layout; -using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -17,30 +14,22 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModFlashlight : ModFlashlight { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 1.5f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 250; + public override float DefaultFlashlightSize => 200; protected override Flashlight CreateFlashlight() => new TaikoFlashlight(this, playfield); - private TaikoPlayfield playfield; + private TaikoPlayfield playfield = null!; public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { @@ -50,27 +39,29 @@ namespace osu.Game.Rulesets.Taiko.Mods private class TaikoFlashlight : Flashlight { - private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); + private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); private readonly TaikoPlayfield taikoPlayfield; public TaikoFlashlight(TaikoModFlashlight modFlashlight, TaikoPlayfield taikoPlayfield) : base(modFlashlight) { this.taikoPlayfield = taikoPlayfield; - FlashlightSize = getSizeFor(0); + + FlashlightSize = adjustSize(GetSize()); + FlashlightSmoothness = 1.4f; AddLayout(flashlightProperties); } - private Vector2 getSizeFor(int combo) + private Vector2 adjustSize(float size) { // Preserve flashlight size through the playfield's aspect adjustment. - return new Vector2(0, GetSizeFor(combo) * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); + return new Vector2(0, size * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), getSizeFor(e.NewValue), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), adjustSize(size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; @@ -84,7 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Mods FlashlightPosition = ToLocalSpace(taikoPlayfield.HitTarget.ScreenSpaceDrawQuad.Centre); ClearTransforms(targetMember: nameof(FlashlightSize)); - FlashlightSize = getSizeFor(Combo.Value); + FlashlightSize = adjustSize(Combo.Value); flashlightProperties.Validate(); } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs index ddfc2d1174..68d6305fbf 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs index 7fcd925c04..ba41175461 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.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.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -10,7 +8,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModHardRock : ModHardRock { - public override double ScoreMultiplier => 1.06; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; /// /// 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 e065bb43fd..4708ef9bf0 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -1,9 +1,8 @@ // 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.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -17,8 +16,8 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModHidden : ModHidden, IApplicableToDrawableRuleset { - public override string Description => @"Beats fade out before you hit them!"; - public override double ScoreMultiplier => 1.06; + public override LocalisableString Description => @"Beats fade out before you hit them!"; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; /// /// How far away from the hit target should hitobjects start to fade out. @@ -32,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Mods /// private const float fade_out_duration = 0.375f; - private DrawableTaikoRuleset drawableRuleset; + private DrawableTaikoRuleset drawableRuleset = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs index 874e15406d..0f1e0b2885 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.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.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs index f2a57ecf88..7cb14635ff 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.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.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -10,6 +8,6 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModNightcore : ModNightcore { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModNoFail.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModNoFail.cs index 57ecf0224f..bf1006f1aa 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModNoFail.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModNoFail.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModPerfect.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModPerfect.cs index c65dba243b..b107b14a03 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModPerfect.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModPerfect.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs index f58f59aaf2..c0be0290e6 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs @@ -1,10 +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 System; using System.Linq; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -15,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 a3a644ab99..d1e9ab1428 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs @@ -1,14 +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 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/TaikoModSuddenDeath.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSuddenDeath.cs index 037e376ad2..7a0f6c7cd1 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSuddenDeath.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSuddenDeath.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.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs index c9cba59760..fc3913f56d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs @@ -1,10 +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 System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Beatmaps; @@ -16,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/BarLine.cs b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs index 382035119e..d2eba0eb54 100644 --- a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs @@ -11,14 +11,16 @@ namespace osu.Game.Rulesets.Taiko.Objects { public class BarLine : TaikoHitObject, IBarLine { + private HitObjectProperty major; + + public Bindable MajorBindable => major.Bindable; + public bool Major { - get => MajorBindable.Value; - set => MajorBindable.Value = value; + get => major.Value; + set => major.Value = value; } - public readonly Bindable MajorBindable = new BindableBool(); - public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 04ed6d0b87..98dad96cf6 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -4,7 +4,6 @@ #nullable disable using System; -using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Utils; @@ -13,10 +12,10 @@ 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; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; using osuTK; @@ -30,6 +29,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. /// @@ -40,6 +41,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private Color4 colourIdle; private Color4 colourEngaged; + public override bool DisplayResult => false; + public DrawableDrumRoll() : this(null) { @@ -140,14 +143,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (timeOffset < 0) return; - int countHit = NestedHitObjects.Count(o => o.IsHit); - - if (countHit >= HitObject.RequiredGoodHits) - { - ApplyResult(r => r.Type = countHit >= HitObject.RequiredGreatHits ? HitResult.Great : HitResult.Ok); - } - else - ApplyResult(r => r.Type = r.Judgement.MinResult); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } protected override void UpdateHitStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 705a0a8047..451c5a793b 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Filled = HitObject.FirstTick }); - protected override double MaximumJudgementOffset => HitObject.HitWindow; + public override double MaximumJudgementOffset => HitObject.HitWindow; protected override void CheckForResult(bool userTriggered, double timeOffset) { diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 2451c79772..a6f6edba09 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -16,7 +16,6 @@ using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; @@ -39,6 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private readonly CircularContainer targetRing; private readonly CircularContainer expandingRing; + public override bool DisplayResult => false; + public DrawableSwell() : this(null) { @@ -201,7 +202,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables expandingRing.ScaleTo(1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 260, Easing.OutQuint); if (numHits == HitObject.RequiredHits) - ApplyResult(r => r.Type = HitResult.Great); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } else { @@ -222,7 +223,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables tick.TriggerResult(false); } - ApplyResult(r => r.Type = numHits > HitObject.RequiredHits / 2 ? HitResult.Ok : r.Judgement.MinResult); + ApplyResult(r => r.Type = numHits == HitObject.RequiredHits ? r.Judgement.MaxResult : r.Judgement.MinResult); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 9bbd3670fa..c0c80eaa4a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isProxied = true; - nonProxiedContent.Remove(Content); + nonProxiedContent.Remove(Content, false); proxiedContent.Add(Content); } @@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isProxied = false; - proxiedContent.Remove(Content); + proxiedContent.Remove(Content, false); nonProxiedContent.Add(Content); } @@ -141,7 +141,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); if (MainPiece != null) - Content.Remove(MainPiece); + Content.Remove(MainPiece, true); Content.Add(MainPiece = CreateMainPiece()); } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index e1619e2857..3325eda7cf 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -4,7 +4,6 @@ #nullable disable using osu.Game.Rulesets.Objects.Types; -using System; using System.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -12,7 +11,6 @@ using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.Taiko.Judgements; using osuTK; namespace osu.Game.Rulesets.Taiko.Objects @@ -42,24 +40,12 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public int TickRate = 1; - /// - /// Number of drum roll ticks required for a "Good" hit. - /// - public double RequiredGoodHits { get; protected set; } - - /// - /// Number of drum roll ticks required for a "Great" hit. - /// - public double RequiredGreatHits { get; protected set; } - /// /// The length (in milliseconds) between ticks of this drumroll. /// Half of this value is the hit window of the ticks. /// private double tickSpacing = 100; - private float overallDifficulty = BeatmapDifficulty.DEFAULT_DIFFICULTY; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -70,16 +56,12 @@ namespace osu.Game.Rulesets.Taiko.Objects Velocity = scoringDistance / timingPoint.BeatLength; tickSpacing = timingPoint.BeatLength / TickRate; - overallDifficulty = difficulty.OverallDifficulty; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { createTicks(cancellationToken); - RequiredGoodHits = NestedHitObjects.Count * Math.Min(0.15, 0.05 + 0.10 / 6 * overallDifficulty); - RequiredGreatHits = NestedHitObjects.Count * Math.Min(0.30, 0.10 + 0.20 / 6 * overallDifficulty); - base.CreateNestedHitObjects(cancellationToken); } @@ -106,7 +88,7 @@ namespace osu.Game.Rulesets.Taiko.Objects } } - public override Judgement CreateJudgement() => new TaikoDrumRollJudgement(); + public override Judgement CreateJudgement() => new IgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; @@ -114,6 +96,8 @@ namespace osu.Game.Rulesets.Taiko.Objects public class StrongNestedHit : StrongNestedHitObject { + // The strong hit of the drum roll doesn't actually provide any score. + public override Judgement CreateJudgement() => new IgnoreJudgement(); } #region LegacyBeatmapEncoder diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 20f3304c30..787079bfee 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Audio; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osuTK.Graphics; @@ -14,19 +15,21 @@ namespace osu.Game.Rulesets.Taiko.Objects { public class Hit : TaikoStrongableHitObject, IHasDisplayColour { - public readonly Bindable TypeBindable = new Bindable(); + private HitObjectProperty type; - public Bindable DisplayColour { get; } = new Bindable(COLOUR_CENTRE); + public Bindable TypeBindable => type.Bindable; /// /// The that actuates this . /// public HitType Type { - get => TypeBindable.Value; - set => TypeBindable.Value = value; + get => type.Value; + set => type.Value = value; } + public Bindable DisplayColour { get; } = new Bindable(COLOUR_CENTRE); + public static readonly Color4 COLOUR_CENTRE = Color4Extensions.FromHex(@"bb1177"); public static readonly Color4 COLOUR_RIM = Color4Extensions.FromHex(@"2299bb"); diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs index a7ab1bcd4a..6b5a9ae6d2 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,6 +14,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; using osuTK.Graphics; @@ -32,6 +34,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default private const double pre_beat_transition_time = 80; + private const float flash_opacity = 0.3f; + private Color4 accentColour; /// @@ -152,11 +156,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default }; } + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { if (!effectPoint.KiaiMode) return; + if (drawableHitObject.State.Value == ArmedState.Idle) + { + FlashBox + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); + } + if (beatIndex % timingPoint.TimeSignature.Numerator != 0) return; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs index 7f813e7b27..6bbeb0ed4c 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; @@ -41,7 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy } // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. - AddInternal(backgroundLayer = getDrawableFor("circle")); + AddInternal(backgroundLayer = new LegacyKiaiFlashingDrawable(() => getDrawableFor("circle"))); var foregroundLayer = getDrawableFor("circleoverlay"); if (foregroundLayer != null) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index 80f139a66b..040d8ff965 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -6,8 +6,9 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Skinning; using osuTK.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/Skinning/Legacy/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs index f18d8f0537..101f70b97a 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs @@ -10,8 +10,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.Taiko.UI; using osu.Game.Skinning; using osuTK; @@ -22,13 +20,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// internal class LegacyInputDrum : Container { + private Container content; private LegacyHalfDrum left; private LegacyHalfDrum right; - private Container content; public LegacyInputDrum() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; } [BackgroundDependencyLoader] @@ -114,9 +113,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public readonly Sprite Rim; public readonly Sprite Centre; - [Resolved] - private DrumSampleTriggerSource sampleTriggerSource { get; set; } - public LegacyHalfDrum(bool flipped) { Masking = true; @@ -151,12 +147,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy if (e.Action == CentreAction) { target = Centre; - sampleTriggerSource.Play(HitType.Centre); } else if (e.Action == RimAction) { target = Rim; - sampleTriggerSource.Play(HitType.Rim); } if (target != null) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index 888271f32d..020cdab4dc 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.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; using System.Collections.Generic; using osu.Framework.Audio.Sample; @@ -16,15 +14,20 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public class TaikoLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasHitCircle || hasBarLeft; + private readonly Lazy hasExplosion; + private bool hasHitCircle => GetTexture("taikohitcircle") != null; + private bool hasBarLeft => GetTexture("taiko-bar-left") != null; + public TaikoLegacySkinTransformer(ISkin skin) : base(skin) { hasExplosion = new Lazy(() => GetTexture(getHitName(TaikoSkinComponents.TaikoExplosionGreat)) != null); } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponent component) { if (component is GameplaySkinComponent) { @@ -44,14 +47,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy return null; case TaikoSkinComponents.InputDrum: - if (GetTexture("taiko-bar-left") != null) + if (hasBarLeft) return new LegacyInputDrum(); return null; case TaikoSkinComponents.CentreHit: case TaikoSkinComponents.RimHit: - if (GetTexture("taikohitcircle") != null) + if (hasHitCircle) return new LegacyHit(taikoComponent.Component); return null; @@ -151,7 +154,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); } - public override ISample GetSample(ISampleInfo sampleInfo) + public override ISample? GetSample(ISampleInfo sampleInfo) { if (sampleInfo is HitSampleInfo hitSampleInfo) return base.GetSample(new LegacyTaikoSampleInfo(hitSampleInfo)); @@ -173,9 +176,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { foreach (string name in base.LookupNames) yield return name.Insert(name.LastIndexOf('/') + 1, "taiko-"); - - foreach (string name in base.LookupNames) - yield return name; } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoInputManager.cs b/osu.Game.Rulesets.Taiko/TaikoInputManager.cs index 1eab133725..d36fe5d496 100644 --- a/osu.Game.Rulesets.Taiko/TaikoInputManager.cs +++ b/osu.Game.Rulesets.Taiko/TaikoInputManager.cs @@ -4,11 +4,13 @@ #nullable disable using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko { + [Cached] // Used for touch input, see DrumTouchInputArea. public class TaikoInputManager : RulesetInputManager { public TaikoInputManager(RulesetInfo ruleset) diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 223e268d7f..dc36bc0320 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -1,34 +1,33 @@ // 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.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Mods; -using osu.Game.Rulesets.Taiko.UI; -using osu.Game.Rulesets.UI; +using System; using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Rulesets.Taiko.Replays; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.Graphics; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Difficulty; -using osu.Game.Rulesets.Taiko.Scoring; -using osu.Game.Scoring; -using System; -using System.Linq; -using osu.Framework.Extensions.EnumExtensions; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Legacy; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; @@ -36,7 +35,7 @@ namespace osu.Game.Rulesets.Taiko { public class TaikoRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor(); @@ -44,10 +43,21 @@ namespace osu.Game.Rulesets.Taiko public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap, this); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new TaikoLegacySkinTransformer(skin); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case LegacySkin: + return new TaikoLegacySkinTransformer(skin); + } + + return null; + } public const string SHORT_NAME = "taiko"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.MouseLeft, TaikoAction.LeftCentre), @@ -192,15 +202,12 @@ namespace osu.Game.Rulesets.Taiko }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { - case HitResult.SmallTickHit: - return "drum tick"; - case HitResult.SmallBonus: - return "strong bonus"; + return "bonus"; } return base.GetDisplayNameForHitResult(result); diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index b4ea158320..58e703b8be 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -8,18 +8,18 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.UI; -using osu.Game.Rulesets.Taiko.Replays; using osu.Framework.Input; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Rulesets.Timing; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Skinning; @@ -56,6 +56,8 @@ namespace osu.Game.Rulesets.Taiko.UI RelativeSizeAxes = Axes.X, Depth = float.MaxValue }); + + KeyBindingInputManager.Add(new DrumTouchInputArea()); } protected override void UpdateAfterChildren() diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSamplePlayer.cs b/osu.Game.Rulesets.Taiko/UI/DrumSamplePlayer.cs new file mode 100644 index 0000000000..b65e2af3d8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumSamplePlayer.cs @@ -0,0 +1,59 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class DrumSamplePlayer : CompositeDrawable, IKeyBindingHandler + { + private readonly DrumSampleTriggerSource leftRimSampleTriggerSource; + private readonly DrumSampleTriggerSource leftCentreSampleTriggerSource; + private readonly DrumSampleTriggerSource rightCentreSampleTriggerSource; + private readonly DrumSampleTriggerSource rightRimSampleTriggerSource; + + public DrumSamplePlayer(HitObjectContainer hitObjectContainer) + { + InternalChildren = new Drawable[] + { + leftRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + leftCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + rightCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + rightRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + }; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case TaikoAction.LeftRim: + leftRimSampleTriggerSource.Play(HitType.Rim); + break; + + case TaikoAction.LeftCentre: + leftCentreSampleTriggerSource.Play(HitType.Centre); + break; + + case TaikoAction.RightCentre: + rightCentreSampleTriggerSource.Play(HitType.Centre); + break; + + case TaikoAction.RightRim: + rightRimSampleTriggerSource.Play(HitType.Rim); + break; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs b/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs new file mode 100644 index 0000000000..a7d9bd18c5 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs @@ -0,0 +1,243 @@ +// 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 System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// An overlay that captures and displays osu!taiko mouse and touch input. + /// + public class DrumTouchInputArea : VisibilityContainer + { + // visibility state affects our child. we always want to handle input. + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + + private KeyBindingContainer keyBindingContainer = null!; + + private readonly Dictionary trackedActions = new Dictionary(); + + private Container mainContent = null!; + + private QuarterCircle leftCentre = null!; + private QuarterCircle rightCentre = null!; + private QuarterCircle leftRim = null!; + private QuarterCircle rightRim = null!; + + [BackgroundDependencyLoader] + private void load(TaikoInputManager taikoInputManager, OsuColour colours) + { + Debug.Assert(taikoInputManager.KeyBindingContainer != null); + keyBindingContainer = taikoInputManager.KeyBindingContainer; + + // Container should handle input everywhere. + RelativeSizeAxes = Axes.Both; + + const float centre_region = 0.80f; + + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = 350, + Y = 20, + Masking = true, + FillMode = FillMode.Fit, + Children = new Drawable[] + { + mainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + leftRim = new QuarterCircle(TaikoAction.LeftRim, colours.Blue) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = -2, + }, + rightRim = new QuarterCircle(TaikoAction.RightRim, colours.Blue) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = 2, + Rotation = 90, + }, + leftCentre = new QuarterCircle(TaikoAction.LeftCentre, colours.Pink) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = -2, + Scale = new Vector2(centre_region), + }, + rightCentre = new QuarterCircle(TaikoAction.RightCentre, colours.Pink) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = 2, + Scale = new Vector2(centre_region), + Rotation = 90, + } + } + }, + } + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // Hide whenever the keyboard is used. + Hide(); + return false; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!validMouse(e)) + return false; + + handleDown(e.Button, e.ScreenSpaceMousePosition); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (!validMouse(e)) + return; + + handleUp(e.Button); + base.OnMouseUp(e); + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + handleDown(e.Touch.Source, e.ScreenSpaceTouchDownPosition); + return true; + } + + protected override void OnTouchUp(TouchUpEvent e) + { + handleUp(e.Touch.Source); + base.OnTouchUp(e); + } + + private void handleDown(object source, Vector2 position) + { + Show(); + + TaikoAction taikoAction = getTaikoActionFromInput(position); + + // Not too sure how this can happen, but let's avoid throwing. + if (trackedActions.ContainsKey(source)) + return; + + trackedActions.Add(source, taikoAction); + keyBindingContainer.TriggerPressed(taikoAction); + } + + private void handleUp(object source) + { + keyBindingContainer.TriggerReleased(trackedActions[source]); + trackedActions.Remove(source); + } + + private bool validMouse(MouseButtonEvent e) => + leftRim.Contains(e.ScreenSpaceMouseDownPosition) || rightRim.Contains(e.ScreenSpaceMouseDownPosition); + + private TaikoAction getTaikoActionFromInput(Vector2 inputPosition) + { + bool centreHit = leftCentre.Contains(inputPosition) || rightCentre.Contains(inputPosition); + bool leftSide = ToLocalSpace(inputPosition).X < DrawWidth / 2; + + if (leftSide) + return centreHit ? TaikoAction.LeftCentre : TaikoAction.LeftRim; + + return centreHit ? TaikoAction.RightCentre : TaikoAction.RightRim; + } + + protected override void PopIn() + { + mainContent.FadeIn(500, Easing.OutQuint); + } + + protected override void PopOut() + { + mainContent.FadeOut(300); + } + + private class QuarterCircle : CompositeDrawable, IKeyBindingHandler + { + private readonly Circle overlay; + + private readonly TaikoAction handledAction; + + private readonly Circle circle; + + public override bool Contains(Vector2 screenSpacePos) => circle.Contains(screenSpacePos); + + public QuarterCircle(TaikoAction handledAction, Color4 colour) + { + this.handledAction = handledAction; + RelativeSizeAxes = Axes.Both; + + FillMode = FillMode.Fit; + + InternalChildren = new Drawable[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + circle = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Multiply(1.4f).Darken(2.8f), + Alpha = 0.8f, + Scale = new Vector2(2), + }, + overlay = new Circle + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Colour = colour, + Scale = new Vector2(2), + } + } + }, + }; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == handledAction) + overlay.FadeTo(1f, 80, Easing.OutQuint); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == handledAction) + overlay.FadeOut(1000, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 55e920ece2..054f98e18f 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -12,8 +12,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.UI; +using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osuTK; @@ -26,14 +25,10 @@ namespace osu.Game.Rulesets.Taiko.UI { private const float middle_split = 0.025f; - [Cached] - private DrumSampleTriggerSource sampleTriggerSource; - - public InputDrum(HitObjectContainer hitObjectContainer) + public InputDrum() { - sampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer); - - RelativeSizeAxes = Axes.Both; + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; } [BackgroundDependencyLoader] @@ -41,12 +36,31 @@ namespace osu.Game.Rulesets.Taiko.UI { Children = new Drawable[] { - new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new Container + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new DefaultInputDrum()) { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + }, + }; + } + + private class DefaultInputDrum : AspectContainer + { + public DefaultInputDrum() + { + RelativeSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, Scale = new Vector2(0.9f), - Children = new Drawable[] + Children = new[] { new TaikoHalfDrum(false) { @@ -71,131 +85,123 @@ namespace osu.Game.Rulesets.Taiko.UI CentreAction = TaikoAction.RightCentre } } - }), - sampleTriggerSource - }; - } - - /// - /// A half-drum. Contains one centre and one rim hit. - /// - private class TaikoHalfDrum : Container, IKeyBindingHandler - { - /// - /// The key to be used for the rim of the half-drum. - /// - public TaikoAction RimAction; - - /// - /// The key to be used for the centre of the half-drum. - /// - public TaikoAction CentreAction; - - private readonly Sprite rim; - private readonly Sprite rimHit; - private readonly Sprite centre; - private readonly Sprite centreHit; - - [Resolved] - private DrumSampleTriggerSource sampleTriggerSource { get; set; } - - public TaikoHalfDrum(bool flipped) - { - Masking = true; - - Children = new Drawable[] - { - rim = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both - }, - rimHit = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Blending = BlendingParameters.Additive, - }, - centre = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.7f) - }, - centreHit = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.7f), - Alpha = 0, - Blending = BlendingParameters.Additive - } }; } - [BackgroundDependencyLoader] - private void load(TextureStore textures, OsuColour colours) + /// + /// A half-drum. Contains one centre and one rim hit. + /// + private class TaikoHalfDrum : Container, IKeyBindingHandler { - rim.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer"); - rimHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer-hit"); - centre.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner"); - centreHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner-hit"); + /// + /// The key to be used for the rim of the half-drum. + /// + public TaikoAction RimAction; - rimHit.Colour = colours.Blue; - centreHit.Colour = colours.Pink; - } + /// + /// The key to be used for the centre of the half-drum. + /// + public TaikoAction CentreAction; - public bool OnPressed(KeyBindingPressEvent e) - { - Drawable target = null; - Drawable back = null; + private readonly Sprite rim; + private readonly Sprite rimHit; + private readonly Sprite centre; + private readonly Sprite centreHit; - if (e.Action == CentreAction) + public TaikoHalfDrum(bool flipped) { - target = centreHit; - back = centre; + Masking = true; - sampleTriggerSource.Play(HitType.Centre); - } - else if (e.Action == RimAction) - { - target = rimHit; - back = rim; - - sampleTriggerSource.Play(HitType.Rim); + Children = new Drawable[] + { + rim = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }, + rimHit = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + }, + centre = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.7f) + }, + centreHit = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.7f), + Alpha = 0, + Blending = BlendingParameters.Additive + } + }; } - if (target != null) + [BackgroundDependencyLoader] + private void load(TextureStore textures, OsuColour colours) { - const float scale_amount = 0.05f; - const float alpha_amount = 0.5f; + rim.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer"); + rimHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer-hit"); + centre.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner"); + centreHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner-hit"); - const float down_time = 40; - const float up_time = 1000; - - back.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint) - .Then() - .ScaleTo(1, up_time, Easing.OutQuint); - - target.Animate( - t => t.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint), - t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time, Easing.OutQuint) - ).Then( - t => t.ScaleTo(1, up_time, Easing.OutQuint), - t => t.FadeOut(up_time, Easing.OutQuint) - ); + rimHit.Colour = colours.Blue; + centreHit.Colour = colours.Pink; } - return false; - } + public bool OnPressed(KeyBindingPressEvent e) + { + Drawable target = null; + Drawable back = null; - public void OnReleased(KeyBindingReleaseEvent e) - { + if (e.Action == CentreAction) + { + target = centreHit; + back = centre; + } + else if (e.Action == RimAction) + { + target = rimHit; + back = rim; + } + + if (target != null) + { + const float scale_amount = 0.05f; + const float alpha_amount = 0.5f; + + const float down_time = 40; + const float up_time = 1000; + + back.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint) + .Then() + .ScaleTo(1, up_time, Easing.OutQuint); + + target.Animate( + t => t.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint), + t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time, Easing.OutQuint) + ).Then( + t => t.ScaleTo(1, up_time, Easing.OutQuint), + t => t.FadeOut(up_time, Easing.OutQuint) + ); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 4ef7c24464..cc71ba5401 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -34,6 +35,11 @@ namespace osu.Game.Rulesets.Taiko.UI /// public const float DEFAULT_HEIGHT = 200; + /// + /// Whether the hit target should be nudged further towards the left area, matching the stable "classic" position. + /// + public Bindable ClassicHitTargetPosition = new BindableBool(); + private Container hitExplosionContainer; private Container kiaiExplosionContainer; private JudgementContainer judgementContainer; @@ -45,8 +51,8 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly IDictionary explosionPools = new Dictionary(); private ProxyContainer topLevelHitContainer; + private InputDrum inputDrum; private Container rightArea; - private Container leftArea; /// /// is purposefully not called on this to prevent i.e. being able to interact @@ -54,14 +60,43 @@ namespace osu.Game.Rulesets.Taiko.UI /// private BarLinePlayfield barLinePlayfield; - private Container hitTargetOffsetContent; + private Container playfieldContent; + private Container playfieldOverlay; [BackgroundDependencyLoader] private void load(OsuColour colours) { + inputDrum = new InputDrum + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + }; + InternalChildren = new[] { new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()), + new Container + { + Name = "Left overlay", + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + BorderColour = colours.Gray0, + Children = new[] + { + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), + inputDrum.CreateProxy(), + } + }, + mascot = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Mascot), _ => Empty()) + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.TopLeft, + RelativePositionAxes = Axes.Y, + RelativeSizeAxes = Axes.None, + Y = 0.2f + }, rightArea = new Container { Name = "Right area", @@ -71,7 +106,7 @@ namespace osu.Game.Rulesets.Taiko.UI { new Container { - Name = "Masked elements before hit objects", + Name = "Elements before hit objects", RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, Children = new[] @@ -86,22 +121,28 @@ namespace osu.Game.Rulesets.Taiko.UI } } }, - hitTargetOffsetContent = new Container + new Container { + Name = "Masked hit objects content", + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = playfieldContent = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + barLinePlayfield = new BarLinePlayfield(), + HitObjectContainer, + } + } + }, + playfieldOverlay = new Container + { + Name = "Elements after hit objects", RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - barLinePlayfield = new BarLinePlayfield(), - new Container - { - Name = "Hit objects", - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - HitObjectContainer, - drumRollHitContainer = new DrumRollHitContainer() - } - }, + drumRollHitContainer = new DrumRollHitContainer(), kiaiExplosionContainer = new Container { Name = "Kiai hit explosions", @@ -117,36 +158,16 @@ namespace osu.Game.Rulesets.Taiko.UI }, } }, - leftArea = new Container - { - Name = "Left overlay", - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - BorderColour = colours.Gray0, - Children = new Drawable[] - { - new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), - new InputDrum(HitObjectContainer) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - } - }, - mascot = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Mascot), _ => Empty()) - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.TopLeft, - RelativePositionAxes = Axes.Y, - RelativeSizeAxes = Axes.None, - Y = 0.2f - }, topLevelHitContainer = new ProxyContainer { Name = "Top level hit objects", RelativeSizeAxes = Axes.Both, }, drumRollHitContainer.CreateProxy(), + new DrumSamplePlayer(HitObjectContainer), + // this is added at the end of the hierarchy to receive input before taiko objects. + // but is proxied below everything to not cover visual effects such as hit explosions. + inputDrum, }; RegisterPool(50); @@ -193,8 +214,9 @@ namespace osu.Game.Rulesets.Taiko.UI // Padding is required to be updated for elements which are based on "absolute" X sized elements. // This is basically allowing for correct alignment as relative pieces move around them. - rightArea.Padding = new MarginPadding { Left = leftArea.DrawWidth }; - hitTargetOffsetContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; + rightArea.Padding = new MarginPadding { Left = inputDrum.Width }; + playfieldContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; + playfieldOverlay.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; mascot.Scale = new Vector2(DrawHeight / DEFAULT_HEIGHT); } @@ -295,6 +317,9 @@ namespace osu.Game.Rulesets.Taiko.UI break; default: + if (!result.Type.IsScorable()) + break; + judgementContainer.Add(judgementPools[result.Type].Get(j => { j.Apply(result, judgedObject); diff --git a/osu.Game.Tests/Audio/SampleInfoEqualityTest.cs b/osu.Game.Tests/Audio/SampleInfoEqualityTest.cs index d05eb7994b..149096608f 100644 --- a/osu.Game.Tests/Audio/SampleInfoEqualityTest.cs +++ b/osu.Game.Tests/Audio/SampleInfoEqualityTest.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 NUnit.Framework; using osu.Game.Audio; diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 9acd3a6cab..fdd0167ed3 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -306,7 +306,7 @@ namespace osu.Game.Tests.Beatmaps.Formats new Color4(128, 255, 128, 255), new Color4(255, 187, 255, 255), new Color4(255, 177, 140, 255), - new Color4(100, 100, 100, 100), + new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; Assert.AreEqual(expectedColors.Length, comboColors.Count); for (int i = 0; i < expectedColors.Length; i++) @@ -919,5 +919,30 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(controlPoints[1].Position, Is.Not.EqualTo(Vector2.Zero)); } } + + [Test] + public void TestNaNControlPoints() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("nan-control-points.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo; + + Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(1)); + Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(2)); + + Assert.That(controlPoints.TimingPointAt(1000).BeatLength, Is.EqualTo(500)); + + Assert.That(controlPoints.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1)); + Assert.That(controlPoints.DifficultyPointAt(3000).SliderVelocity, Is.EqualTo(1)); + +#pragma warning disable 618 + Assert.That(((LegacyBeatmapDecoder.LegacyDifficultyControlPoint)controlPoints.DifficultyPointAt(2000)).GenerateTicks, Is.False); + Assert.That(((LegacyBeatmapDecoder.LegacyDifficultyControlPoint)controlPoints.DifficultyPointAt(3000)).GenerateTicks, Is.True); +#pragma warning restore 618 + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index aa6fc1f309..cd6e5e7919 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -67,6 +67,24 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeTaikoReplay() + { + var decoder = new TestLegacyScoreDecoder(); + + using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay.osr")) + { + var score = decoder.Parse(resourceStream); + + Assert.AreEqual(1, score.ScoreInfo.Ruleset.OnlineID); + Assert.AreEqual(4, score.ScoreInfo.Statistics[HitResult.Great]); + Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.LargeBonus]); + Assert.AreEqual(4, score.ScoreInfo.MaxCombo); + + Assert.That(score.Replay.Frames, Is.Not.Empty); + } + } + [TestCase(3, true)] [TestCase(6, false)] [TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)] diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 57dded8ee4..d9e80fa111 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -97,6 +97,25 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestCorrectAnimationStartTime() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("animation-starts-before-alpha.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); + Assert.AreEqual(1, background.Elements.Count); + + Assert.AreEqual(2000, background.Elements[0].StartTime); + // This property should be used in DrawableStoryboardAnimation as a starting point for animation playback. + Assert.AreEqual(1000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); + } + } + [Test] public void TestOutOfOrderStartTimes() { @@ -117,6 +136,26 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestEarliestStartTimeWithLoopAlphas() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("loop-containing-earlier-non-zero-fade.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); + Assert.AreEqual(2, background.Elements.Count); + + Assert.AreEqual(1000, background.Elements[0].StartTime); + Assert.AreEqual(1000, background.Elements[1].StartTime); + + Assert.AreEqual(1000, storyboard.EarliestEventTime); + } + } + [Test] public void TestDecodeVariableWithSuffix() { diff --git a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs index f3673b0e7f..339063633a 100644 --- a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs @@ -14,7 +14,12 @@ namespace osu.Game.Tests.Beatmaps.Formats public class ParsingTest { [Test] - public void TestNaNHandling() => allThrow("NaN"); + public void TestNaNHandling() + { + allThrow("NaN"); + Assert.That(Parsing.ParseFloat("NaN", allowNaN: true), Is.NaN); + Assert.That(Parsing.ParseDouble("NaN", allowNaN: true), Is.NaN); + } [Test] public void TestBadStringHandling() => allThrow("Random string 123"); diff --git a/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs new file mode 100644 index 0000000000..4ef466efbf --- /dev/null +++ b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs @@ -0,0 +1,138 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Beatmaps +{ + [HeadlessTest] + public class WorkingBeatmapManagerTest : OsuTestScene + { + private BeatmapManager beatmaps = null!; + + private BeatmapSetInfo importedSet = null!; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio, RulesetStore rulesets) + { + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + }); + } + + [Test] + public void TestGetWorkingBeatmap() => AddStep("run test", () => + { + Assert.That(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()), Is.Not.Null); + }); + + [Test] + public void TestCachedRetrievalNoFiles() => AddStep("run test", () => + { + var beatmap = importedSet.Beatmaps.First(); + + Assert.That(beatmap.BeatmapSet?.Files, Is.Empty); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(first, Is.SameAs(second)); + Assert.That(first.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + }); + + [Test] + public void TestCachedRetrievalWithFiles() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(first, Is.SameAs(second)); + Assert.That(first.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + }); + + [Test] + public void TestForcedRefetchRetrievalNoFiles() => AddStep("run test", () => + { + var beatmap = importedSet.Beatmaps.First(); + + Assert.That(beatmap.BeatmapSet?.Files, Is.Empty); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap, true); + Assert.That(first, Is.Not.SameAs(second)); + }); + + [Test] + public void TestForcedRefetchRetrievalWithFiles() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap, true); + Assert.That(first, Is.Not.SameAs(second)); + }); + + [Test] + public void TestSavePreservesCollections() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + var working = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(working.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + string initialHash = working.BeatmapInfo.MD5Hash; + + var preserveCollection = new BeatmapCollection("test contained"); + preserveCollection.BeatmapMD5Hashes.Add(initialHash); + + var noNewCollection = new BeatmapCollection("test not contained"); + + Realm.Write(r => + { + r.Add(preserveCollection); + r.Add(noNewCollection); + }); + + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Contain(initialHash)); + Assert.That(noNewCollection.BeatmapMD5Hashes, Does.Not.Contain(initialHash)); + + beatmaps.Save(working.BeatmapInfo, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + + string finalHash = working.BeatmapInfo.MD5Hash; + + Assert.That(finalHash, Is.Not.SameAs(initialHash)); + + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Not.Contain(initialHash)); + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Contain(finalHash)); + Assert.That(noNewCollection.BeatmapMD5Hashes, Does.Not.Contain(finalHash)); + }); + } +} diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index 9a8f29647d..604b87dc4c 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -5,12 +5,15 @@ using System; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Collections.IO @@ -29,7 +32,11 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, new MemoryStream()); - Assert.That(osu.CollectionManager.Collections.Count, Is.Zero); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.Zero); + }); } finally { @@ -49,18 +56,22 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.EqualTo(2)); - // Even with no beatmaps imported, collections are tracking the hashes and will continue to. - // In the future this whole mechanism will be replaced with having the collections in realm, - // but until that happens it makes rough sense that we want to track not-yet-imported beatmaps - // and have them associate with collections if/when they become available. + // Even with no beatmaps imported, collections are tracking the hashes and will continue to. + // In the future this whole mechanism will be replaced with having the collections in realm, + // but until that happens it makes rough sense that we want to track not-yet-imported beatmaps + // and have them associate with collections if/when they become available. - Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(1)); + Assert.That(collections[0].Name, Is.EqualTo("First")); + Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(1)); - Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); - Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(12)); + Assert.That(collections[1].Name, Is.EqualTo("Second")); + Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(12)); + }); } finally { @@ -80,13 +91,18 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); - Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(1)); + Assert.That(collections.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); - Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(12)); + Assert.That(collections[0].Name, Is.EqualTo("First")); + Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(1)); + + Assert.That(collections[1].Name, Is.EqualTo("Second")); + Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(12)); + }); } finally { @@ -123,7 +139,11 @@ namespace osu.Game.Tests.Collections.IO } Assert.That(exceptionThrown, Is.False); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.EqualTo(0)); + }); } finally { @@ -148,12 +168,18 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); - // Move first beatmap from second collection into the first. - osu.CollectionManager.Collections[0].BeatmapHashes.Add(osu.CollectionManager.Collections[1].BeatmapHashes[0]); - osu.CollectionManager.Collections[1].BeatmapHashes.RemoveAt(0); + // ReSharper disable once MethodHasAsyncOverload + osu.Realm.Write(realm => + { + var collections = realm.All().ToList(); - // Rename the second collecction. - osu.CollectionManager.Collections[1].Name.Value = "Another"; + // Move first beatmap from second collection into the first. + collections[0].BeatmapMD5Hashes.Add(collections[1].BeatmapMD5Hashes[0]); + collections[1].BeatmapMD5Hashes.RemoveAt(0); + + // Rename the second collecction. + collections[1].Name = "Another"; + }); } finally { @@ -168,13 +194,17 @@ namespace osu.Game.Tests.Collections.IO { var osu = LoadOsuIntoHost(host, true); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(2)); + Assert.That(collections[0].Name, Is.EqualTo("First")); + Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another")); - Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(11)); + Assert.That(collections[1].Name, Is.EqualTo("Another")); + Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(11)); + }); } finally { @@ -187,7 +217,7 @@ namespace osu.Game.Tests.Collections.IO { // intentionally spin this up on a separate task to avoid disposal deadlocks. // see https://github.com/EventStore/EventStore/issues/1179 - await Task.Factory.StartNew(() => osu.CollectionManager.Import(stream).WaitSafely(), TaskCreationOptions.LongRunning); + await Task.Factory.StartNew(() => new LegacyCollectionImporter(osu.Realm).Import(stream).WaitSafely(), TaskCreationOptions.LongRunning); } } } diff --git a/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs b/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs new file mode 100644 index 0000000000..4012e3f851 --- /dev/null +++ b/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Database +{ + [HeadlessTest] + public class BackgroundBeatmapProcessorTests : OsuTestScene, ILocalUserPlayInfo + { + public IBindable IsPlaying => isPlaying; + + private readonly Bindable isPlaying = new Bindable(); + + private BeatmapSetInfo importedSet = null!; + + [BackgroundDependencyLoader] + private void load(OsuGameBase osu) + { + importedSet = BeatmapImportHelper.LoadQuickOszIntoOsu(osu).GetResultSafely(); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Set not playing", () => isPlaying.Value = false); + } + + [Test] + public void TestDifficultyProcessing() + { + AddAssert("Difficulty is initially set", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + + AddStep("Reset difficulty", () => + { + Realm.Write(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + foreach (var b in beatmapSetInfo.Beatmaps) + b.StarRating = -1; + }); + }); + + AddStep("Run background processor", () => + { + Add(new TestBackgroundBeatmapProcessor()); + }); + + AddUntilStep("wait for difficulties repopulated", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + } + + [Test] + public void TestDifficultyProcessingWhilePlaying() + { + AddAssert("Difficulty is initially set", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + + AddStep("Set playing", () => isPlaying.Value = true); + + AddStep("Reset difficulty", () => + { + Realm.Write(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + foreach (var b in beatmapSetInfo.Beatmaps) + b.StarRating = -1; + }); + }); + + AddStep("Run background processor", () => + { + Add(new TestBackgroundBeatmapProcessor()); + }); + + AddWaitStep("wait some", 500); + + AddAssert("Difficulty still not populated", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating == -1); + }); + }); + + AddStep("Set not playing", () => isPlaying.Value = false); + + AddUntilStep("wait for difficulties repopulated", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + } + + public class TestBackgroundBeatmapProcessor : BackgroundBeatmapProcessor + { + protected override int TimeToSleepDuringGameplay => 10; + } + } +} diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 9ee88c0670..56964aa8b2 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -142,7 +142,6 @@ namespace osu.Game.Tests.Database { Task.Run(async () => { - // ReSharper disable once AccessToDisposedClosure var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); Assert.NotNull(beatmapSet); @@ -311,6 +310,7 @@ namespace osu.Game.Tests.Database } finally { + File.Delete(temp); Directory.Delete(extractedFolder, true); } }); @@ -670,6 +670,65 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestImportThenReimportWithNewDifficulty() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + string? pathOriginal = TestResources.GetTestBeatmapForImport(); + + string pathMissingOneBeatmap = pathOriginal.Replace(".osz", "_missing_difficulty.osz"); + + string extractedFolder = $"{pathOriginal}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(pathOriginal)) + zip.WriteToDirectory(extractedFolder); + + // remove one difficulty before first import + new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).Delete(); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(pathMissingOneBeatmap, new ZipWriterOptions(CompressionType.Deflate)); + } + + var firstImport = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + Assert.That(firstImport, Is.Not.Null); + + realm.Run(r => r.Refresh()); + + Assert.That(realm.Realm.All().Where(s => !s.DeletePending), Has.Count.EqualTo(1)); + Assert.That(realm.Realm.All().First(s => !s.DeletePending).Beatmaps, Has.Count.EqualTo(11)); + + // Second import matches first but contains one extra .osu file. + var secondImport = await importer.Import(new ImportTask(pathOriginal)); + Assert.That(secondImport, Is.Not.Null); + + realm.Run(r => r.Refresh()); + + Assert.That(realm.Realm.All(), Has.Count.EqualTo(23)); + Assert.That(realm.Realm.All(), Has.Count.EqualTo(2)); + + Assert.That(realm.Realm.All().Where(s => !s.DeletePending), Has.Count.EqualTo(1)); + Assert.That(realm.Realm.All().First(s => !s.DeletePending).Beatmaps, Has.Count.EqualTo(12)); + + // check the newly "imported" beatmap is not the original. + Assert.That(firstImport?.ID, Is.Not.EqualTo(secondImport?.ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + [Test] public void TestImportThenReimportAfterMissingFiles() { @@ -742,7 +801,7 @@ namespace osu.Game.Tests.Database await realm.Realm.WriteAsync(() => { foreach (var b in imported.Beatmaps) - b.OnlineID = -1; + b.ResetOnlineInfo(); }); deleteBeatmapSet(imported, realm.Realm); diff --git a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs new file mode 100644 index 0000000000..b94cff2a9a --- /dev/null +++ b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs @@ -0,0 +1,600 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Tests.Resources; +using Realms; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; + +namespace osu.Game.Tests.Database +{ + /// + /// Tests the flow where a beatmap is already loaded and an update is applied. + /// + [TestFixture] + public class BeatmapImporterUpdateTests : RealmTest + { + private const int count_beatmaps = 12; + + [Test] + public void TestNewDifficultyAdded() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1, s => !s.DeletePending); + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps - 1)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginal), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 1); + + // check the newly "imported" beatmap is not the original. + Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); + + // Previous beatmap set has no beatmaps so will be completely purged on the spot. + Assert.That(importBeforeUpdate.Value.IsValid, Is.False); + }); + } + + /// + /// Regression test covering https://github.com/ppy/osu/issues/19369 (import potentially duplicating if original has no ). + /// + [Test] + public void TestNewDifficultyAddedNoOnlineID() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + // This test is the same as TestNewDifficultyAdded except for this block. + importBeforeUpdate.PerformWrite(s => + { + s.OnlineID = -1; + foreach (var beatmap in s.Beatmaps) + beatmap.ResetOnlineInfo(); + }); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1, s => !s.DeletePending); + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps - 1)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginal), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 1); + + // check the newly "imported" beatmap is not the original. + Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); + + // Previous beatmap set has no beatmaps so will be completely purged on the spot. + Assert.That(importBeforeUpdate.Value.IsValid, Is.False); + }); + } + + [Test] + public void TestExistingDifficultyModified() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathModified, directory => + { + // Modify one .osu file with different content. + var firstOsuFile = directory.GetFiles("*.osu").First(); + + string existingContent = File.ReadAllText(firstOsuFile.FullName); + + File.WriteAllText(firstOsuFile.FullName, existingContent + "\n# I am new content"); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1, s => !s.DeletePending); + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathModified), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + // should only contain the modified beatmap (others purged). + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(1)); + Assert.That(importAfterUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps + 1); + checkCount(realm, count_beatmaps + 1); + + checkCount(realm, 1, s => !s.DeletePending); + checkCount(realm, 1, s => s.DeletePending); + }); + } + + [Test] + public void TestExistingDifficultyRemoved() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.GreaterThan(-1)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathMissingOneBeatmap), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 2); + + // previous set should contain the removed beatmap still. + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(1)); + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.EqualTo(-1)); + + // Previous beatmap set has no beatmaps so will be completely purged on the spot. + Assert.That(importAfterUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps - 1)); + }); + } + + [Test] + public void TestUpdatedImportContainsNothing() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathEmpty, directory => + { + foreach (var file in directory.GetFiles()) + file.Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathEmpty), importBeforeUpdate.Value); + Assert.That(importAfterUpdate, Is.Null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + + Assert.That(importBeforeUpdate.Value.IsValid, Is.True); + }); + } + + [Test] + public void TestNoChanges() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchive(out string pathOriginalSecond); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginalSecond), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.GreaterThan(-1)); + Assert.That(importBeforeUpdate.ID, Is.EqualTo(importAfterUpdate.ID)); + }); + } + + [Test] + public void TestScoreTransferredOnUnchanged() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + string removedFilename = null!; + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // arbitrary beatmap removal + var fileToRemove = directory.GetFiles("*.osu").First(); + + removedFilename = fileToRemove.Name; + fileToRemove.Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string scoreTargetBeatmapHash = string.Empty; + + importBeforeUpdate.PerformWrite(s => + { + // make sure not to add scores to the same beatmap that is removed in the update. + var beatmapInfo = s.Beatmaps.First(b => b.File?.Filename != removedFilename); + + scoreTargetBeatmapHash = beatmapInfo.Hash; + s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + }); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathMissingOneBeatmap), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 2); + + // score is transferred across to the new set + checkCount(realm, 1); + Assert.That(importAfterUpdate.Value.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash).Scores, Has.Count.EqualTo(1)); + }); + } + + [Test] + public void TestScoreLostOnModification() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string? scoreTargetFilename = string.Empty; + + importBeforeUpdate.PerformWrite(s => + { + var beatmapInfo = s.Beatmaps.Last(); + scoreTargetFilename = beatmapInfo.File?.Filename; + s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + }); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + + using var _ = getBeatmapArchiveWithModifications(out string pathModified, directory => + { + // Modify one .osu file with different content. + var firstOsuFile = directory.GetFiles(scoreTargetFilename).First(); + + string existingContent = File.ReadAllText(firstOsuFile.FullName); + + File.WriteAllText(firstOsuFile.FullName, existingContent + "\n# I am new content"); + }); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathModified), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps + 1); + checkCount(realm, count_beatmaps + 1); + checkCount(realm, 2); + + // score is not transferred due to modifications. + checkCount(realm, 1); + Assert.That(importBeforeUpdate.Value.Beatmaps.AsEnumerable().First(b => b.File?.Filename == scoreTargetFilename).Scores, Has.Count.EqualTo(1)); + Assert.That(importAfterUpdate.Value.Beatmaps.AsEnumerable().First(b => b.File?.Filename == scoreTargetFilename).Scores, Has.Count.EqualTo(0)); + }); + } + + [Test] + public void TestMetadataTransferred() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // arbitrary beatmap removal + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathMissingOneBeatmap), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); + Assert.That(importBeforeUpdate.Value.DateAdded, Is.EqualTo(importAfterUpdate.Value.DateAdded)); + }); + } + + /// + /// If all difficulties in the original beatmap set are in a collection, presume the user also wants new difficulties added. + /// + [TestCase(false)] + [TestCase(true)] + public void TestCollectionTransferNewBeatmap(bool allOriginalBeatmapsInCollection) + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + int beatmapsToAddToCollection = 0; + + importBeforeUpdate.PerformWrite(s => + { + var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); + beatmapsToAddToCollection = s.Beatmaps.Count - (allOriginalBeatmapsInCollection ? 0 : 1); + + for (int i = 0; i < beatmapsToAddToCollection; i++) + beatmapCollection.BeatmapMD5Hashes.Add(s.Beatmaps[i].MD5Hash); + }); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginal), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + importAfterUpdate.PerformRead(updated => + { + updated.Realm.Refresh(); + + string[] hashes = updated.Realm.All().Single().BeatmapMD5Hashes.ToArray(); + + if (allOriginalBeatmapsInCollection) + { + Assert.That(updated.Beatmaps.Count, Is.EqualTo(beatmapsToAddToCollection + 1)); + Assert.That(hashes, Has.Length.EqualTo(updated.Beatmaps.Count)); + } + else + { + // Collection contains one less than the original beatmap, and two less after update (new difficulty included). + Assert.That(updated.Beatmaps.Count, Is.EqualTo(beatmapsToAddToCollection + 2)); + Assert.That(hashes, Has.Length.EqualTo(beatmapsToAddToCollection)); + } + }); + }); + } + + /// + /// If a difficulty in the original beatmap set is modified, the updated version should remain in any collections it was in. + /// + [Test] + public void TestCollectionTransferModifiedBeatmap() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathModified, directory => + { + // Modify one .osu file with different content. + var firstOsuFile = directory.GetFiles("*[Hard]*.osu").First(); + + string existingContent = File.ReadAllText(firstOsuFile.FullName); + + File.WriteAllText(firstOsuFile.FullName, existingContent + "\n# I am new content"); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string originalHash = string.Empty; + + importBeforeUpdate.PerformWrite(s => + { + var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); + originalHash = s.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; + + beatmapCollection.BeatmapMD5Hashes.Add(originalHash); + }); + + // Second import matches first but contains a modified .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathModified), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + importAfterUpdate.PerformRead(updated => + { + updated.Realm.Refresh(); + + string[] hashes = updated.Realm.All().Single().BeatmapMD5Hashes.ToArray(); + string updatedHash = updated.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; + + Assert.That(hashes, Has.Length.EqualTo(1)); + Assert.That(hashes.First(), Is.EqualTo(updatedHash)); + + Assert.That(updatedHash, Is.Not.EqualTo(originalHash)); + }); + }); + } + + private static void checkCount(RealmAccess realm, int expected, Expression>? condition = null) where T : RealmObject + { + var query = realm.Realm.All(); + + if (condition != null) + query = query.Where(condition); + + Assert.That(query, Has.Count.EqualTo(expected)); + } + + private static IDisposable getBeatmapArchiveWithModifications(out string path, Action applyModifications) + { + var cleanup = getBeatmapArchive(out path); + + string extractedFolder = $"{path}_extracted"; + Directory.CreateDirectory(extractedFolder); + + using (var zip = ZipArchive.Open(path)) + zip.WriteToDirectory(extractedFolder); + + applyModifications(new DirectoryInfo(extractedFolder)); + + File.Delete(path); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(path, new ZipWriterOptions(CompressionType.Deflate)); + } + + Directory.Delete(extractedFolder, true); + + return cleanup; + } + + private static IDisposable getBeatmapArchive(out string path, bool quick = true) + { + string beatmapPath = TestResources.GetTestBeatmapForImport(quick); + + path = beatmapPath; + + return new InvokeOnDisposal(() => File.Delete(beatmapPath)); + } + } +} diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs index 3615cebe6a..d853e75db0 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -32,31 +32,29 @@ namespace osu.Game.Tests.Database [Test] public void TestAccessAfterStorageMigrate() { - RunTestWithRealm((realm, storage) => + using (var migratedStorage = new TemporaryNativeStorage("realm-test-migration-target")) { - var beatmap = new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata()); - - Live? liveBeatmap = null; - - realm.Run(r => + RunTestWithRealm((realm, storage) => { - r.Write(_ => r.Add(beatmap)); + var beatmap = new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata()); - liveBeatmap = beatmap.ToLive(realm); - }); + Live? liveBeatmap = null; + + realm.Run(r => + { + r.Write(_ => r.Add(beatmap)); + + liveBeatmap = beatmap.ToLive(realm); + }); - using (var migratedStorage = new TemporaryNativeStorage("realm-test-migration-target")) - { migratedStorage.DeleteDirectory(string.Empty); using (realm.BlockAllOperations("testing")) - { storage.Migrate(migratedStorage); - } Assert.IsFalse(liveBeatmap?.PerformRead(l => l.Hidden)); - } - }); + }); + } } [Test] @@ -341,14 +339,12 @@ namespace osu.Game.Tests.Database liveBeatmap.PerformRead(resolved => { // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. - // ReSharper disable once AccessToDisposedClosure Assert.AreEqual(2, outerRealm.All().Count()); Assert.AreEqual(1, changesTriggered); // can access properties without a crash. Assert.IsFalse(resolved.Hidden); - // ReSharper disable once AccessToDisposedClosure outerRealm.Write(r => { // can use with the main context. diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index d6b3c1ff44..1b1878942b 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -4,11 +4,11 @@ using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO; @@ -20,22 +20,15 @@ namespace osu.Game.Tests.Database [TestFixture] public abstract class RealmTest { - private static readonly TemporaryNativeStorage storage; - - static RealmTest() - { - storage = new TemporaryNativeStorage("realm-test"); - storage.DeleteDirectory(string.Empty); - } - - protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") + protected void RunTestWithRealm([InstantHandle] Action testAction, [CallerMemberName] string caller = "") { using (HeadlessGameHost host = new CleanRunHeadlessGameHost(callingMethodName: caller)) { host.Run(new RealmTestGame(() => { - // ReSharper disable once AccessToDisposedClosure - var testStorage = new OsuStorage(host, storage.GetStorageForDirectory(caller)); + var defaultStorage = host.Storage; + + var testStorage = new OsuStorage(host, defaultStorage); using (var realm = new RealmAccess(testStorage, OsuGameBase.CLIENT_DATABASE_FILENAME)) { @@ -58,7 +51,7 @@ namespace osu.Game.Tests.Database { host.Run(new RealmTestGame(async () => { - var testStorage = storage.GetStorageForDirectory(caller); + var testStorage = host.Storage; using (var realm = new RealmAccess(testStorage, OsuGameBase.CLIENT_DATABASE_FILENAME)) { @@ -116,7 +109,7 @@ namespace osu.Game.Tests.Database private class RealmTestGame : Framework.Game { - public RealmTestGame(Func work) + public RealmTestGame([InstantHandle] Func work) { // ReSharper disable once AsyncVoidLambda Scheduler.Add(async () => @@ -126,7 +119,7 @@ namespace osu.Game.Tests.Database }); } - public RealmTestGame(Action work) + public RealmTestGame([InstantHandle] Action work) { Scheduler.Add(() => { diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index 795e90f543..a5662fa121 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -1,11 +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.Linq; using NUnit.Framework; +using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Difficulty; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Database { @@ -51,5 +59,105 @@ namespace osu.Game.Tests.Database Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged); }); } + + [Test] + public void TestRulesetThrowingOnMethods() + { + RunTestWithRealm((realm, storage) => + { + LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; + LoadTestRuleset.HasImplementations = false; + + var ruleset = new LoadTestRuleset(); + string rulesetShortName = ruleset.RulesetInfo.ShortName; + + realm.Write(r => r.Add(new RulesetInfo(rulesetShortName, ruleset.RulesetInfo.Name, ruleset.RulesetInfo.InstantiationInfo, ruleset.RulesetInfo.OnlineID) + { + Available = true, + })); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + + // Availability is updated on construction of a RealmRulesetStore + var _ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.False); + }); + } + + [Test] + public void TestOutdatedRulesetNotAvailable() + { + RunTestWithRealm((realm, storage) => + { + LoadTestRuleset.Version = "2021.101.0"; + LoadTestRuleset.HasImplementations = true; + + var ruleset = new LoadTestRuleset(); + string rulesetShortName = ruleset.RulesetInfo.ShortName; + + realm.Write(r => r.Add(new RulesetInfo(rulesetShortName, ruleset.RulesetInfo.Name, ruleset.RulesetInfo.InstantiationInfo, ruleset.RulesetInfo.OnlineID) + { + Available = true, + })); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + + // Availability is updated on construction of a RealmRulesetStore + var _ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.False); + + // Simulate the ruleset getting updated + LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; + var __ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + }); + } + + private class LoadTestRuleset : Ruleset + { + public override string RulesetAPIVersionSupported => Version; + + public static bool HasImplementations = true; + + public static string Version { get; set; } = CURRENT_RULESET_API_VERSION; + + public override IEnumerable GetModsFor(ModType type) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return Array.Empty(); + } + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return new DrawableOsuRuleset(new OsuRuleset(), beatmap, mods); + } + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return new OsuBeatmapConverter(beatmap, new OsuRuleset()); + } + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); + } + + public override string Description => "outdated ruleset"; + public override string ShortName => "ruleset-outdated"; + } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs index 9fdd49823e..50e6087526 100644 --- a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs @@ -7,10 +7,12 @@ using System.Linq; using Moq; using NUnit.Framework; using osu.Framework.Audio.Track; +using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Visual; namespace osu.Game.Tests.Editing.Checks { @@ -109,7 +111,7 @@ namespace osu.Game.Tests.Editing.Checks /// The bitrate of the audio file the beatmap uses. private Mock getMockWorkingBeatmap(int? audioBitrate) { - var mockTrack = new Mock(); + var mockTrack = new Mock(new FramedClock(), "virtual"); mockTrack.SetupGet(t => t.Bitrate).Returns(audioBitrate); var mockWorkingBeatmap = new Mock(); diff --git a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs index af5588f509..f91e83a556 100644 --- a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs @@ -9,6 +9,7 @@ using System.Linq; using JetBrains.Annotations; using Moq; using NUnit.Framework; +using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; @@ -55,7 +56,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestAcceptable() { - var context = getContext(new Texture(1920, 1080)); + var context = getContext(new DummyRenderer().CreateTexture(1920, 1080)); Assert.That(check.Run(context), Is.Empty); } @@ -63,7 +64,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestTooHighResolution() { - var context = getContext(new Texture(3840, 2160)); + var context = getContext(new DummyRenderer().CreateTexture(3840, 2160)); var issues = check.Run(context).ToList(); @@ -74,7 +75,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestLowResolution() { - var context = getContext(new Texture(640, 480)); + var context = getContext(new DummyRenderer().CreateTexture(640, 480)); var issues = check.Run(context).ToList(); @@ -85,7 +86,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestTooLowResolution() { - var context = getContext(new Texture(100, 100)); + var context = getContext(new DummyRenderer().CreateTexture(100, 100)); var issues = check.Run(context).ToList(); @@ -96,7 +97,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestTooUncompressed() { - var context = getContext(new Texture(1920, 1080), new MemoryStream(new byte[1024 * 1024 * 3])); + var context = getContext(new DummyRenderer().CreateTexture(1920, 1080), new MemoryStream(new byte[1024 * 1024 * 3])); var issues = check.Run(context).ToList(); @@ -107,7 +108,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestStreamClosed() { - var background = new Texture(1920, 1080); + var background = new DummyRenderer().CreateTexture(1920, 1080); var stream = new Mock(new byte[1024 * 1024]); var context = getContext(background, stream.Object); diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index c2df9e5e09..a98f931e7a 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -23,13 +21,13 @@ namespace osu.Game.Tests.Editing [HeadlessTest] public class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene { - private TestHitObjectComposer composer; + private TestHitObjectComposer composer = null!; [Cached(typeof(EditorBeatmap))] [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; - protected override Container Content { get; } + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; public TestSceneHitObjectComposerDistanceSnapping() { @@ -40,15 +38,9 @@ namespace osu.Game.Tests.Editing { editorBeatmap = new EditorBeatmap(new OsuBeatmap { - BeatmapInfo = - { - Ruleset = new OsuRuleset().RulesetInfo, - }, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, }), - Content = new Container - { - RelativeSizeAxes = Axes.Both, - } + Content }, }); } @@ -79,9 +71,9 @@ namespace osu.Game.Tests.Editing [TestCase(1)] [TestCase(2)] - public void TestSpeedMultiplier(float multiplier) + public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) { - assertSnapDistance(100 * multiplier, new HitObject + assertSnapDistance(100, new HitObject { DifficultyControlPoint = new DifficultyControlPoint { @@ -205,8 +197,8 @@ namespace osu.Game.Tests.Editing assertSnappedDistance(400, 400); } - private void assertSnapDistance(float expectedDistance, HitObject hitObject = null) - => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(hitObject ?? new HitObject()) == expectedDistance); + private void assertSnapDistance(float expectedDistance, HitObject? hitObject = null) + => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(hitObject ?? new HitObject()), () => Is.EqualTo(expectedDistance)); private void assertDurationToDistance(double duration, float expectedDistance) => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(new HitObject(), duration) == expectedDistance); diff --git a/osu.Game.Tests/Extensions/StringDehumanizeExtensionsTest.cs b/osu.Game.Tests/Extensions/StringDehumanizeExtensionsTest.cs new file mode 100644 index 0000000000..e7490b461b --- /dev/null +++ b/osu.Game.Tests/Extensions/StringDehumanizeExtensionsTest.cs @@ -0,0 +1,85 @@ +// 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.Globalization; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Extensions; + +namespace osu.Game.Tests.Extensions +{ + [TestFixture] + public class StringDehumanizeExtensionsTest + { + [Test] + [TestCase("single", "Single")] + [TestCase("example word", "ExampleWord")] + [TestCase("mixed Casing test", "MixedCasingTest")] + [TestCase("PascalCase", "PascalCase")] + [TestCase("camelCase", "CamelCase")] + [TestCase("snake_case", "SnakeCase")] + [TestCase("kebab-case", "KebabCase")] + [TestCase("i will not break in a different culture", "IWillNotBreakInADifferentCulture", "tr-TR")] + public void TestToPascalCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToPascalCase(), Is.EqualTo(expectedOutput)); + } + + [Test] + [TestCase("single", "single")] + [TestCase("example word", "exampleWord")] + [TestCase("mixed Casing test", "mixedCasingTest")] + [TestCase("PascalCase", "pascalCase")] + [TestCase("camelCase", "camelCase")] + [TestCase("snake_case", "snakeCase")] + [TestCase("kebab-case", "kebabCase")] + [TestCase("I will not break in a different culture", "iWillNotBreakInADifferentCulture", "tr-TR")] + public void TestToCamelCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToCamelCase(), Is.EqualTo(expectedOutput)); + } + + [Test] + [TestCase("single", "single")] + [TestCase("example word", "example_word")] + [TestCase("mixed Casing test", "mixed_casing_test")] + [TestCase("PascalCase", "pascal_case")] + [TestCase("camelCase", "camel_case")] + [TestCase("snake_case", "snake_case")] + [TestCase("kebab-case", "kebab_case")] + [TestCase("I will not break in a different culture", "i_will_not_break_in_a_different_culture", "tr-TR")] + public void TestToSnakeCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToSnakeCase(), Is.EqualTo(expectedOutput)); + } + + [Test] + [TestCase("single", "single")] + [TestCase("example word", "example-word")] + [TestCase("mixed Casing test", "mixed-casing-test")] + [TestCase("PascalCase", "pascal-case")] + [TestCase("camelCase", "camel-case")] + [TestCase("snake_case", "snake-case")] + [TestCase("kebab-case", "kebab-case")] + [TestCase("I will not break in a different culture", "i-will-not-break-in-a-different-culture", "tr-TR")] + public void TestToKebabCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToKebabCase(), Is.EqualTo(expectedOutput)); + } + + private IDisposable temporaryCurrentCulture(string? cultureName) + { + var storedCulture = CultureInfo.CurrentCulture; + + if (cultureName != null) + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + + return new InvokeOnDisposal(() => CultureInfo.CurrentCulture = storedCulture); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 1e10fbb2bc..9701a32951 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -10,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; diff --git a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs index 0395ae9d99..abd734b96c 100644 --- a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs +++ b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs @@ -41,13 +41,11 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); - Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); }); AddStep("start clock", () => gameplayClockContainer.Start()); - AddUntilStep("elapsed greater than zero", () => gameplayClockContainer.GameplayClock.ElapsedFrameTime > 0); + AddUntilStep("elapsed greater than zero", () => gameplayClockContainer.ElapsedFrameTime > 0); } [Test] @@ -58,22 +56,20 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); - Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); }); AddStep("start clock", () => gameplayClockContainer.Start()); - AddUntilStep("current time greater 2000", () => gameplayClockContainer.GameplayClock.CurrentTime > 2000); + AddUntilStep("current time greater 2000", () => gameplayClockContainer.CurrentTime > 2000); double timeAtReset = 0; AddStep("reset clock", () => { - timeAtReset = gameplayClockContainer.GameplayClock.CurrentTime; + timeAtReset = gameplayClockContainer.CurrentTime; gameplayClockContainer.Reset(); }); - AddAssert("current time < time at reset", () => gameplayClockContainer.GameplayClock.CurrentTime < timeAtReset); + AddAssert("current time < time at reset", () => gameplayClockContainer.CurrentTime < timeAtReset); } [Test] @@ -102,8 +98,6 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { working = new ClockBackedTestWorkingBeatmap(new OsuRuleset().RulesetInfo, new FramedClock(new ManualClock()), Audio); - working.LoadTrack(); - Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); gameplayClockContainer.Reset(startClock: !whileStopped); diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 9e1d786d87..fb9d841d99 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -15,6 +16,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay @@ -91,6 +93,52 @@ namespace osu.Game.Tests.Gameplay Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); } + [Test] + public void TestFailScore() + { + var beatmap = new Beatmap + { + HitObjects = + { + new TestHitObject(), + new TestHitObject(HitResult.LargeTickHit), + new TestHitObject(HitResult.SmallTickHit), + new TestHitObject(HitResult.SmallBonus), + new TestHitObject(), + new TestHitObject(HitResult.LargeTickHit), + new TestHitObject(HitResult.SmallTickHit), + new TestHitObject(HitResult.LargeBonus), + } + }; + + var scoreProcessor = new ScoreProcessor(new OsuRuleset()); + scoreProcessor.ApplyBeatmap(beatmap); + + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], beatmap.HitObjects[0].CreateJudgement()) { Type = HitResult.Ok }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[1], beatmap.HitObjects[1].CreateJudgement()) { Type = HitResult.LargeTickHit }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[2], beatmap.HitObjects[2].CreateJudgement()) { Type = HitResult.SmallTickMiss }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[3], beatmap.HitObjects[3].CreateJudgement()) { Type = HitResult.SmallBonus }); + + var score = new ScoreInfo { Ruleset = new OsuRuleset().RulesetInfo }; + scoreProcessor.FailScore(score); + + Assert.That(score.Rank, Is.EqualTo(ScoreRank.F)); + Assert.That(score.Passed, Is.False); + Assert.That(score.Statistics.Sum(kvp => kvp.Value), Is.EqualTo(4)); + Assert.That(score.MaximumStatistics.Sum(kvp => kvp.Value), Is.EqualTo(8)); + + Assert.That(score.Statistics[HitResult.Ok], Is.EqualTo(1)); + Assert.That(score.Statistics[HitResult.LargeTickHit], Is.EqualTo(1)); + Assert.That(score.Statistics[HitResult.SmallTickMiss], Is.EqualTo(1)); + Assert.That(score.Statistics[HitResult.SmallBonus], Is.EqualTo(1)); + + Assert.That(score.MaximumStatistics[HitResult.Perfect], Is.EqualTo(2)); + Assert.That(score.MaximumStatistics[HitResult.LargeTickHit], Is.EqualTo(2)); + Assert.That(score.MaximumStatistics[HitResult.SmallTickHit], Is.EqualTo(2)); + Assert.That(score.MaximumStatistics[HitResult.SmallBonus], Is.EqualTo(1)); + Assert.That(score.MaximumStatistics[HitResult.LargeBonus], Is.EqualTo(1)); + } + private class TestJudgement : Judgement { public override HitResult MaxResult { get; } @@ -100,5 +148,17 @@ namespace osu.Game.Tests.Gameplay MaxResult = maxResult; } } + + private class TestHitObject : HitObject + { + private readonly HitResult maxResult; + + public TestHitObject(HitResult maxResult = HitResult.Perfect) + { + this.maxResult = maxResult; + } + + public override Judgement CreateJudgement() => new TestJudgement(maxResult); + } } } diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index a9c6bacc65..577933eae3 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -11,8 +11,10 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Configuration; @@ -36,6 +38,9 @@ namespace osu.Game.Tests.Gameplay [Resolved] private OsuConfigManager config { get; set; } + [Resolved] + private GameHost host { get; set; } + [Test] public void TestRetrieveTopLevelSample() { @@ -69,11 +74,9 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); Add(gameplayContainer = new MasterGameplayClockContainer(working, 0) { - IsPaused = { Value = true }, Child = new FrameStabilityContainer { Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) @@ -81,12 +84,15 @@ namespace osu.Game.Tests.Gameplay }); }); - AddStep("reset clock", () => gameplayContainer.Start()); + AddStep("reset clock", () => gameplayContainer.Reset(startClock: true)); AddUntilStep("sample played", () => sample.RequestedPlaying); AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); } + /// + /// Sample at 0ms, start time at 1000ms (so the sample should not be played). + /// [Test] public void TestSampleHasLifetimeEndWithInitialClockTime() { @@ -96,19 +102,18 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); const double start_time = 1000; Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time) { - StartTime = start_time, - IsPaused = { Value = true }, Child = new FrameStabilityContainer { Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) } }); + + gameplayContainer.Reset(start_time); }); AddStep("start time", () => gameplayContainer.Start()); @@ -138,11 +143,11 @@ namespace osu.Game.Tests.Gameplay beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) { - Clock = gameplayContainer.GameplayClock + Clock = gameplayContainer }); }); - AddStep("start", () => gameplayContainer.Start()); + AddStep("reset clock", () => gameplayContainer.Reset(startClock: true)); AddUntilStep("sample played", () => sample.IsPlayed); AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); @@ -204,6 +209,7 @@ namespace osu.Game.Tests.Gameplay #region IResourceStorageProvider + public IRenderer Renderer => host.Renderer; public AudioManager AudioManager => Audio; public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index 32b6dc649c..23ca31ee42 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -10,7 +10,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Platform; -using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Tests.Resources; namespace osu.Game.Tests @@ -47,7 +47,7 @@ namespace osu.Game.Tests public class TestOsuGameBase : OsuGameBase { - public CollectionManager CollectionManager { get; private set; } + public RealmAccess Realm => Dependencies.Get(); private readonly bool withBeatmap; @@ -62,8 +62,6 @@ namespace osu.Game.Tests // Beatmap must be imported before the collection manager is loaded. if (withBeatmap) BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely(); - - AddInternal(CollectionManager = new CollectionManager(Storage)); } } } diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs index a6f68b2836..4101652c49 100644 --- a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs +++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.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 NUnit.Framework; using osu.Game.Beatmaps; @@ -17,7 +15,7 @@ namespace osu.Game.Tests.Mods [TestFixture] public class ModDifficultyAdjustTest { - private TestModDifficultyAdjust testMod; + private TestModDifficultyAdjust testMod = null!; [SetUp] public void Setup() @@ -148,7 +146,7 @@ namespace osu.Game.Tests.Mods yield return new TestModDifficultyAdjust(); } - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) { throw new System.NotImplementedException(); } diff --git a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs index e94ee40acd..cd6879cf01 100644 --- a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs +++ b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.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 NUnit.Framework; using osu.Game.Online.API; using osu.Game.Rulesets.Osu; diff --git a/osu.Game.Tests/Mods/ModSettingsTest.cs b/osu.Game.Tests/Mods/ModSettingsTest.cs index 607b585d33..b9ea1f2567 100644 --- a/osu.Game.Tests/Mods/ModSettingsTest.cs +++ b/osu.Game.Tests/Mods/ModSettingsTest.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 NUnit.Framework; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 22be1a3f01..aa41fd830b 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -1,12 +1,11 @@ // 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 Moq; using NUnit.Framework; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Utils; @@ -164,19 +163,19 @@ namespace osu.Game.Tests.Mods new object[] { new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() }, - null + Array.Empty() }, // invalid free mod is valid for local. new object[] { new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - null + Array.Empty() }, // valid pair. new object[] { new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - null + Array.Empty() }, }; @@ -216,13 +215,13 @@ namespace osu.Game.Tests.Mods new object[] { new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - null + Array.Empty() }, // valid pair. new object[] { new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - null + Array.Empty() }, }; @@ -256,19 +255,19 @@ namespace osu.Game.Tests.Mods new object[] { new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() }, - null, + Array.Empty(), }, // incompatible pair with derived class is valid for free mods. new object[] { new Mod[] { new OsuModDeflate(), new OsuModSpinIn() }, - null, + Array.Empty(), }, // valid pair. new object[] { new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - null + Array.Empty() }, }; @@ -277,12 +276,12 @@ namespace osu.Game.Tests.Mods { bool isValid = ModUtils.CheckValidForGameplay(inputMods, out var invalid); - Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } [TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))] @@ -290,12 +289,12 @@ namespace osu.Game.Tests.Mods { bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, out var invalid); - Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } [TestCaseSource(nameof(invalid_free_mod_test_scenarios))] @@ -303,12 +302,12 @@ namespace osu.Game.Tests.Mods { bool isValid = ModUtils.CheckValidFreeModsForMultiplayer(inputMods, out var invalid); - Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } public abstract class CustomMod1 : Mod, IModCompatibilitySpecification @@ -322,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; @@ -333,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/MultiModIncompatibilityTest.cs b/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs index 3c69adcb59..b8a3828a64 100644 --- a/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs +++ b/osu.Game.Tests/Mods/MultiModIncompatibilityTest.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; using System.Collections.Generic; using System.Linq; @@ -29,10 +27,10 @@ namespace osu.Game.Tests.Mods [TestCase(typeof(ManiaRuleset))] public void TestAllMultiModsFromRulesetAreIncompatible(Type rulesetType) { - var ruleset = (Ruleset)Activator.CreateInstance(rulesetType); + var ruleset = Activator.CreateInstance(rulesetType) as Ruleset; Assert.That(ruleset, Is.Not.Null); - var allMultiMods = getMultiMods(ruleset); + var allMultiMods = getMultiMods(ruleset!); Assert.Multiple(() => { diff --git a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs index f608d020d4..dd105787fa 100644 --- a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs +++ b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.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.Bindables; diff --git a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs index 08007503c6..7df5448ff7 100644 --- a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs +++ b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs @@ -1,11 +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 System.Collections.Generic; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets; @@ -33,7 +32,7 @@ namespace osu.Game.Tests.Mods return Array.Empty(); } - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => throw new NotImplementedException(); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); @@ -62,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/BeatmapSetInfoEqualityTest.cs b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs index de23b012c1..a229331ef0 100644 --- a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs +++ b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs @@ -4,9 +4,12 @@ #nullable disable using System; +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Models; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.NonVisual { @@ -23,6 +26,100 @@ namespace osu.Game.Tests.NonVisual Assert.IsTrue(ourInfo.MatchesOnlineID(otherInfo)); } + [Test] + public void TestAudioEqualityNoFile() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualityCaseSensitivity() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + // empty by default so let's set it.. + beatmapSetA.Beatmaps.First().Metadata.AudioFile = "audio.mp3"; + beatmapSetB.Beatmaps.First().Metadata.AudioFile = "audio.mp3"; + + addAudioFile(beatmapSetA, "abc", "AuDiO.mP3"); + addAudioFile(beatmapSetB, "abc", "audio.mp3"); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualitySameHash() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + addAudioFile(beatmapSetA, "abc"); + addAudioFile(beatmapSetB, "abc"); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualityDifferentHash() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + addAudioFile(beatmapSetA); + addAudioFile(beatmapSetB); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualityBeatmapInfoSameHash() + { + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(2); + + addAudioFile(beatmapSet); + + var beatmap1 = beatmapSet.Beatmaps.First(); + var beatmap2 = beatmapSet.Beatmaps.Last(); + + Assert.AreNotEqual(beatmap1, beatmap2); + Assert.IsTrue(beatmap1.AudioEquals(beatmap2)); + } + + [Test] + public void TestAudioEqualityBeatmapInfoDifferentHash() + { + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(2); + + const string filename1 = "audio1.mp3"; + const string filename2 = "audio2.mp3"; + + addAudioFile(beatmapSet, filename: filename1); + addAudioFile(beatmapSet, filename: filename2); + + var beatmap1 = beatmapSet.Beatmaps.First(); + var beatmap2 = beatmapSet.Beatmaps.Last(); + + Assert.AreNotEqual(beatmap1, beatmap2); + + beatmap1.Metadata.AudioFile = filename1; + beatmap2.Metadata.AudioFile = filename2; + + Assert.IsFalse(beatmap1.AudioEquals(beatmap2)); + } + + private static void addAudioFile(BeatmapSetInfo beatmapSetInfo, string hash = null, string filename = null) + { + beatmapSetInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = hash ?? Guid.NewGuid().ToString() }, filename ?? "audio.mp3")); + } + [Test] public void TestDatabasedWithDatabased() { 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/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index bd0617515b..da32edb8fb 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -118,17 +118,31 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.IsNull(filterCriteria.BPM.Max); } - private static readonly object[] length_query_examples = + private static readonly object[] correct_length_query_examples = { - new object[] { "6ms", TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(1) }, new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) }, new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) }, new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) }, new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) }, + new object[] { "7m27s", TimeSpan.FromSeconds(447), TimeSpan.FromSeconds(1) }, + new object[] { "7:27", TimeSpan.FromSeconds(447), TimeSpan.FromSeconds(1) }, + new object[] { "1h2m3s", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) }, + new object[] { "1h2m3.5s", TimeSpan.FromSeconds(3723.5), TimeSpan.FromSeconds(1) }, + new object[] { "1:2:3", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) }, + new object[] { "1:02:03", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) }, + new object[] { "6", TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(1) }, + new object[] { "6.5", TimeSpan.FromSeconds(6.5), TimeSpan.FromSeconds(1) }, + new object[] { "6.5s", TimeSpan.FromSeconds(6.5), TimeSpan.FromSeconds(1) }, + new object[] { "6.5m", TimeSpan.FromMinutes(6.5), TimeSpan.FromMinutes(1) }, + new object[] { "6h5m", TimeSpan.FromMinutes(365), TimeSpan.FromMinutes(1) }, + new object[] { "65m", TimeSpan.FromMinutes(65), TimeSpan.FromMinutes(1) }, + new object[] { "90s", TimeSpan.FromSeconds(90), TimeSpan.FromSeconds(1) }, + new object[] { "80m20s", TimeSpan.FromSeconds(4820), TimeSpan.FromSeconds(1) }, + new object[] { "1h20s", TimeSpan.FromSeconds(3620), TimeSpan.FromSeconds(1) }, }; [Test] - [TestCaseSource(nameof(length_query_examples))] + [TestCaseSource(nameof(correct_length_query_examples))] public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale) { string query = $"length={lengthQuery} time"; @@ -140,6 +154,29 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max); } + private static readonly object[] incorrect_length_query_examples = + { + new object[] { "7.5m27s" }, + new object[] { "7m27" }, + new object[] { "7m7m7m" }, + new object[] { "7m70s" }, + new object[] { "5s6m" }, + new object[] { "0:" }, + new object[] { ":0" }, + new object[] { "0:3:" }, + new object[] { "3:15.5" }, + }; + + [Test] + [TestCaseSource(nameof(incorrect_length_query_examples))] + public void TestInvalidLengthQueries(string lengthQuery) + { + string query = $"length={lengthQuery} time"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(false, filterCriteria.Length.HasFilter); + } + [Test] public void TestApplyDivisorQueries() { @@ -154,6 +191,16 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive); } + [Test] + public void TestPartialStatusMatch() + { + const string query = "status=r"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(BeatmapOnlineStatus.Ranked, filterCriteria.OnlineStatus.Min); + Assert.AreEqual(BeatmapOnlineStatus.Ranked, filterCriteria.OnlineStatus.Max); + } + [Test] public void TestApplyStatusQueries() { diff --git a/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs new file mode 100644 index 0000000000..363c7a459e --- /dev/null +++ b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Timing; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class GameplayClockContainerTest + { + [TestCase(0)] + [TestCase(1)] + public void TestTrueGameplayRateWithGameplayAdjustment(double underlyingClockRate) + { + var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); + var gameplayClock = new TestGameplayClockContainer(framedClock); + + Assert.That(gameplayClock.GetTrueGameplayRate(), Is.EqualTo(2)); + } + + private class TestGameplayClockContainer : GameplayClockContainer + { + public TestGameplayClockContainer(IFrameBasedClock underlyingClock) + : base(underlyingClock) + { + AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(2.0)); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/GameplayClockTest.cs b/osu.Game.Tests/NonVisual/GameplayClockTest.cs deleted file mode 100644 index 5b8aacd281..0000000000 --- a/osu.Game.Tests/NonVisual/GameplayClockTest.cs +++ /dev/null @@ -1,41 +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 NUnit.Framework; -using osu.Framework.Bindables; -using osu.Framework.Timing; -using osu.Game.Screens.Play; - -namespace osu.Game.Tests.NonVisual -{ - [TestFixture] - public class GameplayClockTest - { - [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()); - - Assert.That(gameplayClock.TrueGameplayRate, Is.EqualTo(0)); - } - - private class TestGameplayClock : GameplayClock - { - public List> MutableNonGameplayAdjustments { get; } = new List>(); - - public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; - - public TestGameplayClock(IFrameBasedClock underlyingClock) - : base(underlyingClock) - { - } - } - } -} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs index e21a3e636f..6297d9cdc7 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -11,7 +11,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Framework.Timing; @@ -27,6 +27,9 @@ namespace osu.Game.Tests.NonVisual.Skinning private const string animation_name = "animation"; private const int frame_count = 6; + [Resolved] + private IRenderer renderer { get; set; } + [Cached(typeof(IAnimationTimeReference))] private TestAnimationTimeReference animationTimeReference = new TestAnimationTimeReference(); @@ -35,9 +38,12 @@ namespace osu.Game.Tests.NonVisual.Skinning [Test] public void TestAnimationTimeReferenceChange() { - ISkin skin = new TestSkin(); + AddStep("get animation", () => + { + ISkin skin = new TestSkin(renderer); + Add(animation = (TextureAnimation)skin.GetAnimation(animation_name, true, false)); + }); - AddStep("get animation", () => Add(animation = (TextureAnimation)skin.GetAnimation(animation_name, true, false))); AddAssert("frame count correct", () => animation.FrameCount == frame_count); assertPlaybackPosition(0); @@ -55,9 +61,16 @@ namespace osu.Game.Tests.NonVisual.Skinning { private static readonly string[] lookup_names = Enumerable.Range(0, frame_count).Select(frame => $"{animation_name}-{frame}").ToArray(); + private readonly IRenderer renderer; + + public TestSkin(IRenderer renderer) + { + this.renderer = renderer; + } + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { - return lookup_names.Contains(componentName) ? Texture.WhitePixel : null; + return lookup_names.Contains(componentName) ? renderer.WhitePixel : null; } public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs index 22aa78838a..ca0d4d3cf3 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs @@ -11,6 +11,8 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Audio; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Database; @@ -141,6 +143,7 @@ namespace osu.Game.Tests.NonVisual.Skinning this.textureStore = textureStore; } + public IRenderer Renderer => new DummyRenderer(); public AudioManager AudioManager => null; public IResourceStore Files => null; public IResourceStore Resources => null; diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 185f85513b..da250c1e05 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -6,13 +6,14 @@ using System; using System.Collections.Generic; 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; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Solo; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -110,30 +111,30 @@ namespace osu.Game.Tests.Online } [Test] - public void TestDeserialiseSubmittableScoreWithEmptyMods() + public void TestDeserialiseSoloScoreWithEmptyMods() { - var score = new SubmittableScore(new ScoreInfo + var score = SoloScoreInfo.ForSubmission(new ScoreInfo { User = new APIUser(), Ruleset = new OsuRuleset().RulesetInfo, }); - var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); Assert.That(deserialised?.Mods.Length, Is.Zero); } [Test] - public void TestDeserialiseSubmittableScoreWithCustomModSetting() + public void TestDeserialiseSoloScoreWithCustomModSetting() { - var score = new SubmittableScore(new ScoreInfo + var score = SoloScoreInfo.ForSubmission(new ScoreInfo { Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2 } } }, User = new APIUser(), Ruleset = new OsuRuleset().RulesetInfo, }); - var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); Assert.That((deserialised?.Mods[0])?.Settings["speed_change"], Is.EqualTo(2)); } @@ -149,6 +150,16 @@ namespace osu.Game.Tests.Online Assert.That(apiMod.Settings["speed_change"], Is.EqualTo(1.01d)); } + [Test] + public void TestSerialisedModSettingPresence() + { + var mod = new TestMod(); + + mod.TestSetting.Value = mod.TestSetting.Default; + JObject serialised = JObject.Parse(JsonConvert.SerializeObject(new APIMod(mod))); + Assert.False(serialised.ContainsKey("settings")); + } + private class TestRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) => new Mod[] @@ -172,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")] @@ -189,35 +200,27 @@ 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")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1.5) { MinValue = 1, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(0.5) { MinValue = 0, MaxValue = 1, - Default = 0.5, - Value = 0.5, Precision = 0.01, }; [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); } private class TestModDifficultyAdjust : ModDifficultyAdjust diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs index a89d68bf15..1d8cbffcdb 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,42 +120,34 @@ 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")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1.5) { MinValue = 1, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(0.5) { MinValue = 0, MaxValue = 1, - Default = 0.5, - Value = 0.5, Precision = 0.01, }; [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); } private class TestModEnum : Mod { 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/Online/TestSceneBeatmapDownloading.cs b/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs index d0176da0e9..641c1ad523 100644 --- a/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs +++ b/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Online { AddStep("download beatmap", () => beatmaps.Download(test_db_model)); - AddStep("cancel download from request", () => beatmaps.GetExistingDownload(test_db_model).Cancel()); + AddStep("cancel download from request", () => beatmaps.GetExistingDownload(test_db_model)!.Cancel()); AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_db_model) == null); AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); @@ -91,7 +91,7 @@ namespace osu.Game.Tests.Online { AddStep("download beatmap", () => beatmaps.Download(test_db_model)); - AddStep("cancel download from notification", () => recentNotification.Close()); + AddStep("cancel download from notification", () => recentNotification.Close(true)); AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_db_model) == null); AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index fcf69bf6f2..e7590df3e0 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; @@ -78,7 +77,7 @@ namespace osu.Game.Tests.Online } }; - beatmaps.AllowImport = new TaskCompletionSource(); + beatmaps.AllowImport.Reset(); testBeatmapFile = TestResources.GetQuickTestBeatmapForImport(); @@ -126,13 +125,13 @@ namespace osu.Game.Tests.Online AddStep("start downloading", () => beatmapDownloader.Download(testBeatmapSet)); addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f)); - AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)).SetProgress(0.4f)); + AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.SetProgress(0.4f)); addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f)); - AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)).TriggerSuccess(testBeatmapFile)); + AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.TriggerSuccess(testBeatmapFile)); addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing); - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddUntilStep("wait for import", () => beatmaps.CurrentImport != null); AddUntilStep("ensure beatmap available", () => beatmaps.IsAvailableLocally(testBeatmapSet)); addAvailabilityCheckStep("state is locally available", BeatmapAvailability.LocallyAvailable); @@ -141,7 +140,7 @@ namespace osu.Game.Tests.Online [Test] public void TestTrackerRespectsSoftDeleting() { - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); @@ -155,7 +154,7 @@ namespace osu.Game.Tests.Online [Test] public void TestTrackerRespectsChecksum() { - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("initially locally available", BeatmapAvailability.LocallyAvailable); @@ -202,13 +201,13 @@ namespace osu.Game.Tests.Online private class TestBeatmapManager : BeatmapManager { - public TaskCompletionSource AllowImport = new TaskCompletionSource(); + public readonly ManualResetEventSlim AllowImport = new ManualResetEventSlim(); public Live CurrentImport { get; private set; } public TestBeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null) - : base(storage, realm, rulesets, api, audioManager, resources, host, defaultBeatmap) + : base(storage, realm, api, audioManager, resources, host, defaultBeatmap) { } @@ -229,7 +228,9 @@ namespace osu.Game.Tests.Online public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) { - testBeatmapManager.AllowImport.Task.WaitSafely(); + if (!testBeatmapManager.AllowImport.Wait(TimeSpan.FromSeconds(10), cancellationToken)) + throw new TimeoutException("Timeout waiting for import to be allowed."); + return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, batchImport, cancellationToken)); } } @@ -246,7 +247,7 @@ namespace osu.Game.Tests.Online => new TestDownloadRequest(set); } - private class TestDownloadRequest : ArchiveDownloadRequest + internal class TestDownloadRequest : ArchiveDownloadRequest { public new void SetProgress(float progress) => base.SetProgress(progress); public new void TriggerSuccess(string filename) => base.TriggerSuccess(filename); diff --git a/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs b/osu.Game.Tests/Online/TestSoloScoreInfoJsonSerialization.cs similarity index 79% rename from osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs rename to osu.Game.Tests/Online/TestSoloScoreInfoJsonSerialization.cs index f0f6727393..8ff0b67b5b 100644 --- a/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestSoloScoreInfoJsonSerialization.cs @@ -6,7 +6,7 @@ using Newtonsoft.Json; using NUnit.Framework; using osu.Game.IO.Serialization; -using osu.Game.Online.Solo; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Online @@ -15,12 +15,12 @@ namespace osu.Game.Tests.Online /// Basic testing to ensure our attribute-based naming is correctly working. /// [TestFixture] - public class TestSubmittableScoreJsonSerialization + public class TestSoloScoreInfoJsonSerialization { [Test] public void TestScoreSerialisationViaExtensionMethod() { - var score = new SubmittableScore(TestResources.CreateTestScoreInfo()); + var score = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo()); string serialised = score.Serialize(); @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Online [Test] public void TestScoreSerialisationWithoutSettings() { - var score = new SubmittableScore(TestResources.CreateTestScoreInfo()); + var score = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo()); string serialised = JsonConvert.SerializeObject(score); 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/Resources/Archives/modified-classic-20220723.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20220723.osk new file mode 100644 index 0000000000..8e7a1b42df Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20220723.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-classic-20220801.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20220801.osk new file mode 100644 index 0000000000..9236e1d77f Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20220801.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20220723.osk b/osu.Game.Tests/Resources/Archives/modified-default-20220723.osk new file mode 100644 index 0000000000..7547162165 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20220723.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk new file mode 100644 index 0000000000..92215cbf86 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk b/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk new file mode 100644 index 0000000000..74ff4f31d5 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk differ diff --git a/osu.Game.Tests/Resources/Replays/taiko-replay.osr b/osu.Game.Tests/Resources/Replays/taiko-replay.osr new file mode 100644 index 0000000000..986b3116ab Binary files /dev/null and b/osu.Game.Tests/Resources/Replays/taiko-replay.osr differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index 91d4eb70e8..9c85f61330 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -128,16 +128,20 @@ namespace osu.Game.Tests.Resources var rulesetInfo = getRuleset(); + string hash = Guid.NewGuid().ToString().ComputeMD5Hash(); + yield return new BeatmapInfo { OnlineID = beatmapId, DifficultyName = $"{version} {beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", StarRating = diff, Length = length, + BeatmapSet = beatmapSet, BPM = bpm, - Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + Hash = hash, + MD5Hash = hash, Ruleset = rulesetInfo, - Metadata = metadata, + Metadata = metadata.DeepClone(), Difficulty = new BeatmapDifficulty { OverallDifficulty = diff, @@ -190,8 +194,16 @@ namespace osu.Game.Tests.Resources [HitResult.LargeTickHit] = 100, [HitResult.LargeTickMiss] = 50, [HitResult.SmallBonus] = 10, - [HitResult.SmallBonus] = 50 + [HitResult.LargeBonus] = 50 }, + MaximumStatistics = new Dictionary + { + [HitResult.Perfect] = 971, + [HitResult.SmallTickHit] = 75, + [HitResult.LargeTickHit] = 150, + [HitResult.SmallBonus] = 10, + [HitResult.LargeBonus] = 50, + } }; private class TestModHardRock : ModHardRock diff --git a/osu.Game.Tests/Resources/animation-starts-before-alpha.osb b/osu.Game.Tests/Resources/animation-starts-before-alpha.osb new file mode 100644 index 0000000000..ceef204f3f --- /dev/null +++ b/osu.Game.Tests/Resources/animation-starts-before-alpha.osb @@ -0,0 +1,5 @@ +[Events] +//Storyboard Layer 0 (Background) +Animation,Background,Centre,"img.jpg",320,240,2,150,LoopForever + S,0,1000,1500,0.08 // animation should start playing from this point in time.. + F,0,2000,,0,1 // .. not this point in time diff --git a/osu.Game.Tests/Resources/loop-containing-earlier-non-zero-fade.osb b/osu.Game.Tests/Resources/loop-containing-earlier-non-zero-fade.osb new file mode 100644 index 0000000000..2ff7f9f56f --- /dev/null +++ b/osu.Game.Tests/Resources/loop-containing-earlier-non-zero-fade.osb @@ -0,0 +1,14 @@ +osu file format v14 + +[Events] +//Storyboard Layer 0 (Background) +Sprite,Background,TopCentre,"img.jpg",320,240 + L,1000,1 + F,0,0,,1 // fade inside a loop with non-zero alpha and an earlier start time should be the true start time.. + F,0,2000,,0 // ..not a zero alpha fade with a later start time + +Sprite,Background,TopCentre,"img.jpg",320,240 + L,2000,1 + F,0,0,24,0 // fade inside a loop with zero alpha but later start time than the top-level zero alpha start time. + F,0,24,48,1 + F,0,1000,,1 // ..so this should be the true start time diff --git a/osu.Game.Tests/Resources/nan-control-points.osu b/osu.Game.Tests/Resources/nan-control-points.osu new file mode 100644 index 0000000000..dcaa705116 --- /dev/null +++ b/osu.Game.Tests/Resources/nan-control-points.osu @@ -0,0 +1,15 @@ +osu file format v14 + +[TimingPoints] + +// NaN bpm (should be rejected) +0,NaN,4,2,0,100,1,0 + +// 120 bpm +1000,500,4,2,0,100,1,0 + +// NaN slider velocity +2000,NaN,4,3,0,100,0,1 + +// 1.0x slider velocity +3000,-100,4,3,0,100,0,1 \ No newline at end of file diff --git a/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs index 2622db464f..4601737558 100644 --- a/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs +++ b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.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 NUnit.Framework; using osu.Framework.Audio.Track; using osu.Framework.Timing; @@ -19,8 +17,8 @@ namespace osu.Game.Tests.Rulesets.Mods private const double start_time = 1000; private const double duration = 9000; - private TrackVirtual track; - private OsuPlayfield playfield; + private TrackVirtual track = null!; + private OsuPlayfield playfield = null!; [SetUp] public void SetUp() diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index ff282fff62..7a45d1e7cf 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; @@ -306,7 +307,7 @@ namespace osu.Game.Tests.Rulesets.Scoring HitObjects = { new TestHitObject(result) } }); - Assert.That(scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, new ScoreInfo + Assert.That(scoreProcessor.ComputeScore(ScoringMode.Standardised, new ScoreInfo { Ruleset = new TestRuleset().RulesetInfo, MaxCombo = result.AffectsCombo() ? 1 : 0, @@ -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().ComputeScore(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/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index 9f732be9e3..29534348dc 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -15,11 +15,12 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; @@ -77,9 +78,9 @@ namespace osu.Game.Tests.Rulesets { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(ParentTextureStore = new TestTextureStore()); + dependencies.CacheAs(ParentTextureStore = new TestTextureStore(parent.Get().Renderer)); dependencies.CacheAs(ParentSampleStore = new TestSampleStore()); - dependencies.CacheAs(ParentShaderManager = new TestShaderManager()); + dependencies.CacheAs(ParentShaderManager = new TestShaderManager(parent.Get().Renderer)); return new DrawableRulesetDependencies(new OsuRuleset(), dependencies); } @@ -95,6 +96,11 @@ namespace osu.Game.Tests.Rulesets private class TestTextureStore : TextureStore { + public TestTextureStore(IRenderer renderer) + : base(renderer) + { + } + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null; public bool IsDisposed { get; private set; } @@ -148,8 +154,8 @@ namespace osu.Game.Tests.Rulesets private class TestShaderManager : ShaderManager { - public TestShaderManager() - : base(new ResourceStore()) + public TestShaderManager(IRenderer renderer) + : base(renderer, new ResourceStore()) { } diff --git a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs index f1ecd3b526..320373699d 100644 --- a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs +++ b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs @@ -10,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index c3c10215a5..5c20f46787 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -202,7 +202,7 @@ namespace osu.Game.Tests.Skins.IO skinManager.CurrentSkinInfo.Value.PerformRead(s => { Assert.IsFalse(s.Protected); - Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); @@ -215,7 +215,7 @@ namespace osu.Game.Tests.Skins.IO { Assert.IsFalse(s.Protected); Assert.AreNotEqual(originalSkinId, s.ID); - Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); }); return Task.CompletedTask; @@ -226,7 +226,7 @@ namespace osu.Game.Tests.Skins.IO { var skinManager = osu.Dependencies.Get(); - skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + skinManager.CurrentSkinInfo.Value = skinManager.DefaultClassicSkin.SkinInfo; skinManager.EnsureMutableSkin(); diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index 419eb87b1a..6756f27ecd 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -29,7 +29,7 @@ namespace osu.Game.Tests.Skins new Color4(142, 199, 255, 255), new Color4(255, 128, 128, 255), new Color4(128, 255, 255, 255), - new Color4(100, 100, 100, 100), + new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; Assert.AreEqual(expectedColors.Count, comboColors.Count); diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs new file mode 100644 index 0000000000..989459632e --- /dev/null +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -0,0 +1,131 @@ +// 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 NUnit.Framework; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Audio; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Skins +{ + /// + /// Test that the main components (which are serialised based on namespace/class name) + /// remain compatible with any changes. + /// + /// + /// If this test breaks, check any naming or class structure changes. + /// Migration rules may need to be added to . + /// + [TestFixture] + public class SkinDeserialisationTest + { + private static readonly string[] available_skins = + { + // Covers song progress before namespace changes, and most other components. + "Archives/modified-default-20220723.osk", + "Archives/modified-classic-20220723.osk", + // Covers legacy song progress, UR counter, colour hit error metre. + "Archives/modified-classic-20220801.osk", + // Covers clicks/s counter + "Archives/modified-default-20220818.osk", + // Covers longest combo counter + "Archives/modified-default-20221012.osk" + }; + + /// + /// If this test fails, new test resources should be added to include new components. + /// + [Test] + public void TestSkinnableComponentsCoveredByDeserialisationTests() + { + HashSet instantiatedTypes = new HashSet(); + + foreach (string oskFile in available_skins) + { + using (var stream = TestResources.OpenResource(oskFile)) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + foreach (var target in skin.DrawableComponentInfo) + { + foreach (var info in target.Value) + instantiatedTypes.Add(info.Type); + } + } + } + + var editableTypes = SkinnableInfo.GetAllAvailableDrawables().Where(t => (Activator.CreateInstance(t) as ISkinnableDrawable)?.IsEditable == true); + + Assert.That(instantiatedTypes, Is.EquivalentTo(editableTypes)); + } + + [Test] + public void TestDeserialiseModifiedDefault() + { + using (var stream = TestResources.OpenResource("Archives/modified-default-20220723.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + Assert.That(skin.DrawableComponentInfo, Has.Count.EqualTo(2)); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.MainHUDComponents], Has.Length.EqualTo(9)); + } + } + + [Test] + public void TestDeserialiseModifiedClassic() + { + using (var stream = TestResources.OpenResource("Archives/modified-classic-20220723.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + Assert.That(skin.DrawableComponentInfo, Has.Count.EqualTo(2)); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.MainHUDComponents], Has.Length.EqualTo(6)); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.SongSelect], Has.Length.EqualTo(1)); + + var skinnableInfo = skin.DrawableComponentInfo[SkinnableTarget.SongSelect].First(); + + Assert.That(skinnableInfo.Type, Is.EqualTo(typeof(SkinnableSprite))); + Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); + Assert.That(skinnableInfo.Settings.First().Value, Is.EqualTo("ppy_logo-2.png")); + } + + using (var stream = TestResources.OpenResource("Archives/modified-classic-20220801.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.MainHUDComponents], Has.Length.EqualTo(8)); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); + Assert.That(skin.DrawableComponentInfo[SkinnableTarget.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); + } + } + + private class TestSkin : Skin + { + public TestSkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? storage = null, string configurationFilename = "skin.ini") + : base(skin, resources, storage, configurationFilename) + { + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public override IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public override ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs index 1493c10969..004e253c1f 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs @@ -10,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs index f4cea2c8cc..e82a5b57d9 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs @@ -32,7 +32,6 @@ namespace osu.Game.Tests.Skins imported?.PerformRead(s => { beatmap = beatmaps.GetWorkingBeatmap(s.Beatmaps[0]); - beatmap.LoadTrack(); }); } @@ -40,6 +39,10 @@ namespace osu.Game.Tests.Skins public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo("sample")) != null); [Test] - public void TestRetrieveOggTrack() => AddAssert("track is non-null", () => !(beatmap.Track is TrackVirtual)); + public void TestRetrieveOggTrack() => AddAssert("track is non-null", () => + { + using (var track = beatmap.LoadTrack()) + return track is not TrackVirtual; + }); } } diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index 5452bfc939..b0c26f7280 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -13,7 +13,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs index 1229b63a90..f7c88643fe 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs @@ -6,10 +6,11 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -21,6 +22,9 @@ namespace osu.Game.Tests.Skins [HeadlessTest] public class TestSceneSkinProvidingContainer : OsuTestScene { + [Resolved] + private IRenderer renderer { get; set; } + /// /// Ensures that the first inserted skin after resetting (via source change) /// is always prioritised over others when providing the same resource. @@ -35,7 +39,7 @@ namespace osu.Game.Tests.Skins { var sources = new List(); for (int i = 0; i < 10; i++) - sources.Add(new TestSkin()); + sources.Add(new TestSkin(renderer)); mostPrioritisedSource = sources.First(); @@ -76,12 +80,19 @@ namespace osu.Game.Tests.Skins { public const string TEXTURE_NAME = "virtual-texture"; + private readonly IRenderer renderer; + + public TestSkin(IRenderer renderer) + { + this.renderer = renderer; + } + public Drawable GetDrawableComponent(ISkinComponent component) => throw new System.NotImplementedException(); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { if (componentName == TEXTURE_NAME) - return Texture.WhitePixel; + return renderer.WhitePixel; return null; } diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs index 42c1eeb6d1..22d6d08355 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -1,14 +1,24 @@ // 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.IO; +using System.Linq; +using Moq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Rendering.Dummy; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Database; +using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; @@ -19,9 +29,9 @@ namespace osu.Game.Tests.Skins public class TestSceneSkinResources : OsuTestScene { [Resolved] - private SkinManager skins { get; set; } + private SkinManager skins { get; set; } = null!; - private ISkin skin; + private ISkin skin = null!; [BackgroundDependencyLoader] private void load() @@ -32,5 +42,56 @@ namespace osu.Game.Tests.Skins [Test] public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => skin.GetSample(new SampleInfo("sample")) != null); + + [Test] + public void TestSampleRetrievalOrder() + { + Mock mockResourceProvider = null!; + Mock> mockResourceStore = null!; + List lookedUpFileNames = null!; + + AddStep("setup mock providers provider", () => + { + lookedUpFileNames = new List(); + mockResourceProvider = new Mock(); + mockResourceProvider.Setup(m => m.AudioManager).Returns(Audio); + mockResourceProvider.Setup(m => m.Renderer).Returns(new DummyRenderer()); + mockResourceStore = new Mock>(); + mockResourceStore.Setup(r => r.Get(It.IsAny())) + .Callback(n => lookedUpFileNames.Add(n)) + .Returns(null); + }); + + AddStep("query sample", () => + { + TestSkin testSkin = new TestSkin(new SkinInfo(), mockResourceProvider.Object, new ResourceStore(mockResourceStore.Object)); + testSkin.GetSample(new SampleInfo()); + }); + + AddAssert("sample lookups were in correct order", () => + { + string[] lookups = lookedUpFileNames.Where(f => f.StartsWith(TestSkin.SAMPLE_NAME, StringComparison.Ordinal)).ToArray(); + return Path.GetExtension(lookups[0]) == string.Empty + && Path.GetExtension(lookups[1]) == ".wav" + && Path.GetExtension(lookups[2]) == ".mp3" + && Path.GetExtension(lookups[3]) == ".ogg"; + }); + } + + private class TestSkin : Skin + { + public const string SAMPLE_NAME = "test-sample"; + + public TestSkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? storage = null, string configurationFilename = "skin.ini") + : base(skin, resources, storage, configurationFilename) + { + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public override IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public override ISample GetSample(ISampleInfo sampleInfo) => Samples.AsNonNull().Get(SAMPLE_NAME); + } } } diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index 5aaaca2b2c..d9dfa1d876 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Screens; using osu.Framework.Testing; @@ -43,6 +44,9 @@ namespace osu.Game.Tests.Visual.Background [Resolved] private OsuConfigManager config { get; set; } + [Resolved] + private IRenderer renderer { get; set; } + [SetUpSteps] public void SetUpSteps() { @@ -245,7 +249,7 @@ namespace osu.Game.Tests.Visual.Background Id = API.LocalUser.Value.Id + 1, }); - private WorkingBeatmap createTestWorkingBeatmapWithUniqueBackground() => new UniqueBackgroundTestWorkingBeatmap(Audio); + private WorkingBeatmap createTestWorkingBeatmapWithUniqueBackground() => new UniqueBackgroundTestWorkingBeatmap(renderer, Audio); private WorkingBeatmap createTestWorkingBeatmapWithStoryboard() => new TestWorkingBeatmapWithStoryboard(Audio); private class TestBackgroundScreenDefault : BackgroundScreenDefault @@ -274,12 +278,15 @@ namespace osu.Game.Tests.Visual.Background private class UniqueBackgroundTestWorkingBeatmap : TestWorkingBeatmap { - public UniqueBackgroundTestWorkingBeatmap(AudioManager audioManager) + private readonly IRenderer renderer; + + public UniqueBackgroundTestWorkingBeatmap(IRenderer renderer, AudioManager audioManager) : base(new Beatmap(), null, audioManager) { + this.renderer = renderer; } - protected override Texture GetBackground() => new Texture(1, 1); + protected override Texture GetBackground() => renderer.CreateTexture(1, 1); } private class TestWorkingBeatmapWithStoryboard : TestWorkingBeatmap diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index a44ab41d03..c9920cd01f 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -10,7 +10,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Game.Configuration; using osu.Game.Graphics.Backgrounds; @@ -28,11 +28,9 @@ namespace osu.Game.Tests.Visual.Background [Resolved] private SessionStatics statics { get; set; } - [Cached(typeof(LargeTextureStore))] - private LookupLoggingTextureStore textureStore = new LookupLoggingTextureStore(); - private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private LookupLoggingTextureStore textureStore; private SeasonalBackgroundLoader backgroundLoader; private Container backgroundContainer; @@ -45,15 +43,32 @@ namespace osu.Game.Tests.Visual.Background "Backgrounds/bg3" }; + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + textureStore = new LookupLoggingTextureStore(dependencies.Get()); + dependencies.CacheAs(typeof(LargeTextureStore), textureStore); + + return dependencies; + } + [BackgroundDependencyLoader] private void load(LargeTextureStore wrappedStore) { textureStore.AddStore(wrappedStore); - Add(backgroundContainer = new Container + Child = new DependencyProvidingContainer { - RelativeSizeAxes = Axes.Both - }); + CachedDependencies = new (Type, object)[] + { + (typeof(LargeTextureStore), textureStore) + }, + Child = backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both + } + }; } [SetUp] @@ -156,7 +171,7 @@ namespace osu.Game.Tests.Visual.Background => AddStep("create loader", () => { if (backgroundLoader != null) - Remove(backgroundLoader); + Remove(backgroundLoader, true); Add(backgroundLoader = new SeasonalBackgroundLoader()); }); @@ -193,6 +208,11 @@ namespace osu.Game.Tests.Visual.Background { public List PerformedLookups { get; } = new List(); + public LookupLoggingTextureStore(IRenderer renderer) + : base(renderer) + { + } + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) { PerformedLookups.Add(name); diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index aaccea09d4..917434ae22 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Background private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); Dependencies.Cache(Realm); @@ -244,7 +244,10 @@ namespace osu.Game.Tests.Visual.Background public void TestResumeFromPlayer() { performFullSetup(); - AddStep("Move mouse to Visual Settings", () => InputManager.MoveMouseTo(playerLoader.VisualSettingsPos)); + AddStep("Move mouse to Visual Settings location", () => InputManager.MoveMouseTo(playerLoader.ScreenSpaceDrawQuad.TopRight + + new Vector2(-playerLoader.VisualSettingsPos.ScreenSpaceDrawQuad.Width, + playerLoader.VisualSettingsPos.ScreenSpaceDrawQuad.Height / 2 + ))); AddStep("Resume PlayerLoader", () => player.Restart()); AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs index 6bcc45d97e..f95e3b9990 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs @@ -15,12 +15,14 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Beatmaps { @@ -295,5 +297,22 @@ namespace osu.Game.Tests.Visual.Beatmaps BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); } + + [Test] + public void TestPlayButtonByTouchInput() + { + AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, beatmapSetInfo => new BeatmapCardNormal(beatmapSetInfo))); + + // mimics touch input + AddStep("touch play button area on first card", () => + { + InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("first card is playing", () => firstCard().ChildrenOfType().Single().Playing.Value); + + BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); + } } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs index 3308ffe714..10515fd95f 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Configuration; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; @@ -58,6 +59,7 @@ namespace osu.Game.Tests.Visual.Beatmaps { Anchor = Anchor.Centre, Origin = Anchor.Centre, + State = { Value = DownloadState.NotDownloaded }, Scale = new Vector2(2) }; }); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs index 0af876bfe8..5afda0ad0c 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Beatmaps }; }); AddStep("enable dim", () => thumbnail.Dimmed.Value = true); - AddUntilStep("button visible", () => playButton.IsPresent); + AddUntilStep("button visible", () => playButton.Alpha == 1); AddStep("click button", () => { @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual.Beatmaps AddStep("disable dim", () => thumbnail.Dimmed.Value = false); AddWaitStep("wait some", 3); - AddAssert("button still visible", () => playButton.IsPresent); + AddAssert("button still visible", () => playButton.Alpha == 1); // The track plays in real-time, so we need to check for progress in increments to avoid timeout. AddUntilStep("progress > 0.25", () => thumbnail.ChildrenOfType().Single().Progress.Value > 0.25); @@ -78,7 +78,7 @@ namespace osu.Game.Tests.Visual.Beatmaps AddUntilStep("progress > 0.75", () => thumbnail.ChildrenOfType().Single().Progress.Value > 0.75); AddUntilStep("wait for track to end", () => !playButton.Playing.Value); - AddUntilStep("button hidden", () => !playButton.IsPresent); + AddUntilStep("button hidden", () => playButton.Alpha == 0); } private void iconIs(IconUsage usage) => AddUntilStep("icon is correct", () => playButton.ChildrenOfType().Any(icon => icon.Icon.Equals(usage))); diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 3f30fa367c..77e781bfe4 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.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.Allocation; @@ -27,38 +25,32 @@ namespace osu.Game.Tests.Visual.Collections { protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - private DialogOverlay dialogOverlay; - private CollectionManager manager; - - private RulesetStore rulesets; - private BeatmapManager beatmapManager; - - private ManageCollectionsDialog dialog; + private DialogOverlay dialogOverlay = null!; + private BeatmapManager beatmapManager = null!; + private ManageCollectionsDialog dialog = null!; [BackgroundDependencyLoader] private void load(GameHost host) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesets, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); base.Content.AddRange(new Drawable[] { - manager = new CollectionManager(LocalStorage), Content, dialogOverlay = new DialogOverlay(), }); - Dependencies.Cache(manager); Dependencies.CacheAs(dialogOverlay); } [SetUp] public void SetUp() => Schedule(() => { - manager.Collections.Clear(); + Realm.Write(r => r.RemoveAll()); Child = dialog = new ManageCollectionsDialog(); }); @@ -78,17 +70,17 @@ namespace osu.Game.Tests.Visual.Collections [Test] public void TestLastItemIsPlaceholder() { - AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + AddAssert("last item is placeholder", () => !dialog.ChildrenOfType().Last().Model.IsManaged); } [Test] public void TestAddCollectionExternal() { - AddStep("add collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "First collection" } })); + AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "First collection")))); assertCollectionCount(1); assertCollectionName(0, "First collection"); - AddStep("add another collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "Second collection" } })); + AddStep("add another collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "Second collection")))); assertCollectionCount(2); assertCollectionName(1, "Second collection"); } @@ -108,7 +100,7 @@ namespace osu.Game.Tests.Visual.Collections [Test] public void TestAddCollectionViaPlaceholder() { - DrawableCollectionListItem placeholderItem = null; + DrawableCollectionListItem placeholderItem = null!; AddStep("focus placeholder", () => { @@ -116,24 +108,37 @@ namespace osu.Game.Tests.Visual.Collections InputManager.Click(MouseButton.Left); }); - // Done directly via the collection since InputManager methods cannot add text to textbox... - AddStep("change collection name", () => placeholderItem.Model.Name.Value = "a"); - assertCollectionCount(1); - AddAssert("collection now exists", () => manager.Collections.Contains(placeholderItem.Model)); + assertCollectionCount(0); - AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + AddStep("change collection name", () => + { + placeholderItem.ChildrenOfType().First().Text = "test text"; + InputManager.Key(Key.Enter); + }); + + assertCollectionCount(1); + + AddAssert("last item is placeholder", () => !dialog.ChildrenOfType().Last().Model.IsManaged); } [Test] public void TestRemoveCollectionExternal() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] - { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" } }, - })); + BeatmapCollection first = null!; - AddStep("remove first collection", () => manager.Collections.RemoveAt(0)); + AddStep("add two collections", () => + { + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); + + AddStep("remove first collection", () => Realm.Write(r => r.Remove(first))); assertCollectionCount(1); assertCollectionName(0, "2"); } @@ -143,7 +148,7 @@ namespace osu.Game.Tests.Visual.Collections { AddStep("add dropdown", () => { - Add(new CollectionFilterDropdown + Add(new CollectionDropdown { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -151,21 +156,27 @@ namespace osu.Game.Tests.Visual.Collections Width = 0.4f, }); }); - AddStep("add two collections with same name", () => manager.Collections.AddRange(new[] + AddStep("add two collections with same name", () => Realm.Write(r => r.Add(new[] { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "1" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } }, - })); + new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "1") + { + BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } + }, + }))); } [Test] public void TestRemoveCollectionViaButton() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + AddStep("add two collections", () => Realm.Write(r => r.Add(new[] { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } }, - })); + new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2") + { + BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } + }, + }))); assertCollectionCount(2); @@ -189,19 +200,24 @@ namespace osu.Game.Tests.Visual.Collections AddStep("click confirmation", () => { InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); - InputManager.Click(MouseButton.Left); + InputManager.PressButton(MouseButton.Left); }); assertCollectionCount(0); + + AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); } [Test] public void TestCollectionNotRemovedWhenDialogCancelled() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + AddStep("add collection", () => Realm.Write(r => r.Add(new[] { - new BeatmapCollection { Name = { Value = "1" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } }, - })); + new BeatmapCollection(name: "1") + { + BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } + }, + }))); assertCollectionCount(1); @@ -224,34 +240,67 @@ namespace osu.Game.Tests.Visual.Collections [Test] public void TestCollectionRenamedExternal() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + BeatmapCollection first = null!; + + AddStep("add two collections", () => { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" } }, - })); + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); - AddStep("change first collection name", () => manager.Collections[0].Name.Value = "First"); + assertCollectionName(0, "1"); + assertCollectionName(1, "2"); - assertCollectionName(0, "First"); + AddStep("change first collection name", () => Realm.Write(_ => first.Name = "First")); + + // Item will have moved due to alphabetical sorting. + assertCollectionName(0, "2"); + assertCollectionName(1, "First"); } [Test] public void TestCollectionRenamedOnTextChange() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + BeatmapCollection first = null!; + DrawableCollectionListItem firstItem = null!; + + AddStep("add two collections", () => { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" } }, - })); + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); assertCollectionCount(2); - AddStep("change first collection name", () => dialog.ChildrenOfType().First().Text = "First"); - AddAssert("collection has new name", () => manager.Collections[0].Name.Value == "First"); + AddStep("focus first collection", () => + { + InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("change first collection name", () => + { + firstItem.ChildrenOfType().First().Text = "First"; + InputManager.Key(Key.Enter); + }); + + AddUntilStep("collection has new name", () => first.Name == "First"); } private void assertCollectionCount(int count) - => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsCreated.Value) == count); + => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count() == count + 1); // +1 for placeholder private void assertCollectionName(int index, string name) => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name); diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index cb29475ba5..94a71ed50c 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -190,7 +190,7 @@ namespace osu.Game.Tests.Visual.Components AddAssert("track stopped", () => !track.IsRunning); } - private TestPreviewTrackManager.TestPreviewTrack getTrack() => (TestPreviewTrackManager.TestPreviewTrack)trackManager.Get(null); + private TestPreviewTrackManager.TestPreviewTrack getTrack() => (TestPreviewTrackManager.TestPreviewTrack)trackManager.Get(CreateAPIBeatmapSet()); private TestPreviewTrackManager.TestPreviewTrack getOwnedTrack() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs index 291630fa3a..0df44b9ac4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; @@ -34,9 +35,11 @@ namespace osu.Game.Tests.Visual.Editing { var beatmap = new OsuBeatmap { - BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, }; + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + editorBeatmap = new EditorBeatmap(beatmap, new LegacyBeatmapSkin(beatmap.BeatmapInfo, null)); Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); @@ -50,7 +53,11 @@ namespace osu.Game.Tests.Visual.Editing (typeof(IBeatSnapProvider), editorBeatmap), (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Green)), }, - Child = new ComposeScreen { State = { Value = Visibility.Visible } }, + Children = new Drawable[] + { + editorBeatmap, + new ComposeScreen { State = { Value = Visibility.Visible } }, + } }; }); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs new file mode 100644 index 0000000000..4366b1c0bb --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps.IO; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneDifficultyDelete : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + protected override bool IsolateSavingFromDatabase => false; + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private BeatmapSetInfo importedBeatmapSet = null!; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null!) + => beatmaps.GetWorkingBeatmap(importedBeatmapSet.Beatmaps.First()); + + public override void SetUpSteps() + { + AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game, virtualTrack: true).GetResultSafely()); + base.SetUpSteps(); + } + + [Test] + public void TestDeleteDifficulties() + { + Guid deletedDifficultyID = Guid.Empty; + int countBeforeDeletion = 0; + string beatmapSetHashBefore = string.Empty; + + for (int i = 0; i < 12; i++) + { + // Will be reloaded after each deletion. + AddUntilStep("wait for editor to load", () => Editor?.ReadyForUse == true); + + AddStep("store selected difficulty", () => + { + deletedDifficultyID = EditorBeatmap.BeatmapInfo.ID; + countBeforeDeletion = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count; + beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash; + }); + + AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); + + if (i == 11) + { + // last difficulty shouldn't be able to be deleted. + AddAssert("Delete menu item disabled", () => getDeleteMenuItem().Item.Action.Disabled); + } + else + { + AddStep("click delete", () => getDeleteMenuItem().TriggerClick()); + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); + AddStep("confirm", () => InputManager.Key(Key.Number1)); + + AddAssert($"difficulty {i} is deleted", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.Select(b => b.ID), () => Does.Not.Contain(deletedDifficultyID)); + AddAssert("count decreased by one", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, () => Is.EqualTo(countBeforeDeletion - 1)); + AddAssert("set hash changed", () => Beatmap.Value.BeatmapSetInfo.Hash, () => Is.Not.EqualTo(beatmapSetHashBefore)); + } + } + } + + private DrawableOsuMenuItem getDeleteMenuItem() => this.ChildrenOfType() + .Single(item => item.ChildrenOfType().Any(text => text.Text.ToString().StartsWith("Delete", StringComparison.Ordinal))); + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index f565ca3ef4..80a5b4832b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -55,8 +56,10 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestCreateNewBeatmap() { + AddAssert("status is none", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.None); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID)?.Value.DeletePending == false); + AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); } [Test] @@ -103,6 +106,8 @@ namespace osu.Game.Tests.Visual.Editing */ public void TestAddAudioTrack() { + AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual); + AddAssert("switch track to real track", () => { var setup = Editor.ChildrenOfType().First(); @@ -131,7 +136,22 @@ namespace osu.Game.Tests.Visual.Editing } }); + AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual); AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000); + + AddStep("test play", () => Editor.TestGameplay()); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); + AddStep("confirm save", () => InputManager.Key(Key.Number1)); + + AddUntilStep("wait for return to editor", () => Editor.IsCurrentScreen()); + + AddAssert("track is still not virtual", () => Beatmap.Value.Track is not TrackVirtual); + AddAssert("track length correct", () => Beatmap.Value.Track.Length > 60000); + + AddUntilStep("track not playing", () => !EditorClock.IsRunning); + AddStep("play track", () => InputManager.Key(Key.Space)); + AddUntilStep("wait for track playing", () => EditorClock.IsRunning); } [Test] @@ -190,6 +210,8 @@ namespace osu.Game.Tests.Visual.Editing }); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); + AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = secondDifficultyName); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => @@ -200,7 +222,7 @@ namespace osu.Game.Tests.Visual.Editing return beatmap != null && beatmap.DifficultyName == secondDifficultyName && set != null - && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Beatmaps.Any(b => b.DifficultyName == secondDifficultyName)); + && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Beatmaps.Any(b => b.DifficultyName == secondDifficultyName) && s.Beatmaps.All(b => s.Status == BeatmapOnlineStatus.LocallyModified)); }); } @@ -276,7 +298,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); - AddAssert("status not copied", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.None); + AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); AddAssert("online ID not copied", () => EditorBeatmap.BeatmapInfo.OnlineID == -1); AddStep("save beatmap", () => Editor.Save()); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs index 3be6371f28..319f8ab9dc 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -57,51 +55,51 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestStopAtTrackEnd() { - AddStep("reset clock", () => Clock.Seek(0)); + AddStep("reset clock", () => EditorClock.Seek(0)); - AddStep("start clock", Clock.Start); - AddAssert("clock running", () => Clock.IsRunning); + AddStep("start clock", () => EditorClock.Start()); + AddAssert("clock running", () => EditorClock.IsRunning); - AddStep("seek near end", () => Clock.Seek(Clock.TrackLength - 250)); - AddUntilStep("clock stops", () => !Clock.IsRunning); + AddStep("seek near end", () => EditorClock.Seek(EditorClock.TrackLength - 250)); + AddUntilStep("clock stops", () => !EditorClock.IsRunning); - AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + AddUntilStep("clock stopped at end", () => EditorClock.CurrentTime - EditorClock.TotalAppliedOffset, () => Is.EqualTo(EditorClock.TrackLength)); - AddStep("start clock again", Clock.Start); - AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + AddStep("start clock again", () => EditorClock.Start()); + AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500); } [Test] public void TestWrapWhenStoppedAtTrackEnd() { - AddStep("reset clock", () => Clock.Seek(0)); + AddStep("reset clock", () => EditorClock.Seek(0)); - AddStep("stop clock", Clock.Stop); - AddAssert("clock stopped", () => !Clock.IsRunning); + AddStep("stop clock", () => EditorClock.Stop()); + AddAssert("clock stopped", () => !EditorClock.IsRunning); - AddStep("seek exactly to end", () => Clock.Seek(Clock.TrackLength)); - AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + AddStep("seek exactly to end", () => EditorClock.Seek(EditorClock.TrackLength)); + AddAssert("clock stopped at end", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); - AddStep("start clock again", Clock.Start); - AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + AddStep("start clock again", () => EditorClock.Start()); + AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500); } [Test] public void TestClampWhenSeekOutsideBeatmapBounds() { - AddStep("stop clock", Clock.Stop); + AddStep("stop clock", () => EditorClock.Stop()); - AddStep("seek before start time", () => Clock.Seek(-1000)); - AddAssert("time is clamped to 0", () => Clock.CurrentTime == 0); + AddStep("seek before start time", () => EditorClock.Seek(-1000)); + AddAssert("time is clamped to 0", () => EditorClock.CurrentTime, () => Is.EqualTo(0)); - AddStep("seek beyond track length", () => Clock.Seek(Clock.TrackLength + 1000)); - AddAssert("time is clamped to track length", () => Clock.CurrentTime == Clock.TrackLength); + AddStep("seek beyond track length", () => EditorClock.Seek(EditorClock.TrackLength + 1000)); + AddAssert("time is clamped to track length", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); - AddStep("seek smoothly before start time", () => Clock.SeekSmoothlyTo(-1000)); - AddAssert("time is clamped to 0", () => Clock.CurrentTime == 0); + AddStep("seek smoothly before start time", () => EditorClock.SeekSmoothlyTo(-1000)); + AddUntilStep("time is clamped to 0", () => EditorClock.CurrentTime, () => Is.EqualTo(0)); - AddStep("seek smoothly beyond track length", () => Clock.SeekSmoothlyTo(Clock.TrackLength + 1000)); - AddAssert("time is clamped to track length", () => Clock.CurrentTime == Clock.TrackLength); + AddStep("seek smoothly beyond track length", () => EditorClock.SeekSmoothlyTo(EditorClock.TrackLength + 1000)); + AddUntilStep("time is clamped to track length", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 56435c69a4..6a69347651 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -4,8 +4,10 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Screens.Edit.Components.RadioButtons; namespace osu.Game.Tests.Visual.Editing @@ -13,6 +15,9 @@ namespace osu.Game.Tests.Visual.Editing [TestFixture] public class TestSceneEditorComposeRadioButtons : OsuTestScene { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + public TestSceneEditorComposeRadioButtons() { EditorRadioButtonCollection collection; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 8e325838ff..d7e9cc1bc0 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -6,11 +6,13 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Select; @@ -23,7 +25,9 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestCantExitWithoutSaving() { + AddUntilStep("Wait for dialog overlay load", () => ((Drawable)Game.Dependencies.Get()).IsLoaded); AddRepeatStep("Exit", () => InputManager.Key(Key.Escape), 10); + AddAssert("Sample playback disabled", () => Editor.SamplePlaybackDisabled.Value); AddAssert("Editor is still active screen", () => Game.ScreenStack.CurrentScreen is Editor); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs index c8ca273db5..aa4bccd728 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs @@ -28,6 +28,11 @@ namespace osu.Game.Tests.Visual.Editing { base.LoadComplete(); + Child = new TimingPointVisualiser(Beatmap.Value.Beatmap, 5000) { Clock = EditorClock }; + } + + protected override Beatmap CreateEditorClockBeatmap() + { var testBeatmap = new Beatmap { ControlPointInfo = new ControlPointInfo(), @@ -45,9 +50,7 @@ namespace osu.Game.Tests.Visual.Editing testBeatmap.ControlPointInfo.Add(450, new TimingControlPoint { BeatLength = 100 }); testBeatmap.ControlPointInfo.Add(500, new TimingControlPoint { BeatLength = 307.69230769230802 }); - Beatmap.Value = CreateWorkingBeatmap(testBeatmap); - - Child = new TimingPointVisualiser(testBeatmap, 5000) { Clock = Clock }; + return testBeatmap; } /// @@ -59,18 +62,18 @@ namespace osu.Game.Tests.Visual.Editing reset(); // Forwards - AddStep("Seek(0)", () => Clock.Seek(0)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); - AddStep("Seek(33)", () => Clock.Seek(33)); - AddAssert("Time = 33", () => Clock.CurrentTime == 33); - AddStep("Seek(89)", () => Clock.Seek(89)); - AddAssert("Time = 89", () => Clock.CurrentTime == 89); + AddStep("Seek(0)", () => EditorClock.Seek(0)); + checkTime(0); + AddStep("Seek(33)", () => EditorClock.Seek(33)); + checkTime(33); + AddStep("Seek(89)", () => EditorClock.Seek(89)); + checkTime(89); // Backwards - AddStep("Seek(25)", () => Clock.Seek(25)); - AddAssert("Time = 25", () => Clock.CurrentTime == 25); - AddStep("Seek(0)", () => Clock.Seek(0)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + AddStep("Seek(25)", () => EditorClock.Seek(25)); + checkTime(25); + AddStep("Seek(0)", () => EditorClock.Seek(0)); + checkTime(0); } /// @@ -82,20 +85,20 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(0), Snap", () => Clock.SeekSnapped(0)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); - AddStep("Seek(50), Snap", () => Clock.SeekSnapped(50)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("Seek(100), Snap", () => Clock.SeekSnapped(100)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(175), Snap", () => Clock.SeekSnapped(175)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(350), Snap", () => Clock.SeekSnapped(350)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("Seek(400), Snap", () => Clock.SeekSnapped(400)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("Seek(450), Snap", () => Clock.SeekSnapped(450)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("Seek(0), Snap", () => EditorClock.SeekSnapped(0)); + checkTime(0); + AddStep("Seek(50), Snap", () => EditorClock.SeekSnapped(50)); + checkTime(50); + AddStep("Seek(100), Snap", () => EditorClock.SeekSnapped(100)); + checkTime(100); + AddStep("Seek(175), Snap", () => EditorClock.SeekSnapped(175)); + checkTime(175); + AddStep("Seek(350), Snap", () => EditorClock.SeekSnapped(350)); + checkTime(350); + AddStep("Seek(400), Snap", () => EditorClock.SeekSnapped(400)); + checkTime(400); + AddStep("Seek(450), Snap", () => EditorClock.SeekSnapped(450)); + checkTime(450); } /// @@ -107,18 +110,18 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(24), Snap", () => Clock.SeekSnapped(24)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); - AddStep("Seek(26), Snap", () => Clock.SeekSnapped(26)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("Seek(150), Snap", () => Clock.SeekSnapped(150)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(170), Snap", () => Clock.SeekSnapped(170)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(274), Snap", () => Clock.SeekSnapped(274)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(276), Snap", () => Clock.SeekSnapped(276)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); + AddStep("Seek(24), Snap", () => EditorClock.SeekSnapped(24)); + checkTime(0); + AddStep("Seek(26), Snap", () => EditorClock.SeekSnapped(26)); + checkTime(50); + AddStep("Seek(150), Snap", () => EditorClock.SeekSnapped(150)); + checkTime(100); + AddStep("Seek(170), Snap", () => EditorClock.SeekSnapped(170)); + checkTime(175); + AddStep("Seek(274), Snap", () => EditorClock.SeekSnapped(274)); + checkTime(175); + AddStep("Seek(276), Snap", () => EditorClock.SeekSnapped(276)); + checkTime(350); } /// @@ -129,16 +132,16 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 200", () => Clock.CurrentTime == 200); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(50); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(100); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(200); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(400); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(450); } /// @@ -149,18 +152,18 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(50); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(100); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(175); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(350); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(400); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(450); } /// @@ -172,30 +175,31 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(49)", () => Clock.Seek(49)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("Seek(49.999)", () => Clock.Seek(49.999)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(99)", () => Clock.Seek(99)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(99.999)", () => Clock.Seek(99.999)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 150); - AddStep("Seek(174)", () => Clock.Seek(174)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(349)", () => Clock.Seek(349)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("Seek(399)", () => Clock.Seek(399)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("Seek(449)", () => Clock.Seek(449)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("Seek(49)", () => EditorClock.Seek(49)); + checkTime(49); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(50); + AddStep("Seek(49.999)", () => EditorClock.Seek(49.999)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(100); + AddStep("Seek(99)", () => EditorClock.Seek(99)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(100); + AddStep("Seek(99.999)", () => EditorClock.Seek(99.999)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(150); + AddStep("Seek(174)", () => EditorClock.Seek(174)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(175); + AddStep("Seek(349)", () => EditorClock.Seek(349)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(350); + AddStep("Seek(399)", () => EditorClock.Seek(399)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(400); + AddStep("Seek(449)", () => EditorClock.Seek(449)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(450); } /// @@ -206,17 +210,18 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(450)", () => Clock.Seek(450)); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 150", () => Clock.CurrentTime == 150); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + AddStep("Seek(450)", () => EditorClock.Seek(450)); + checkTime(450); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(400); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(350); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(150); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(50); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(0); } /// @@ -227,19 +232,20 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(450)", () => Clock.Seek(450)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + AddStep("Seek(450)", () => EditorClock.Seek(450)); + checkTime(450); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(400); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(350); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(175); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(100); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(50); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(0); } /// @@ -251,18 +257,19 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(451)", () => Clock.Seek(451)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); - AddStep("Seek(450.999)", () => Clock.Seek(450.999)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); - AddStep("Seek(401)", () => Clock.Seek(401)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("Seek(401.999)", () => Clock.Seek(401.999)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); + AddStep("Seek(451)", () => EditorClock.Seek(451)); + checkTime(451); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(450); + AddStep("Seek(450.999)", () => EditorClock.Seek(450.999)); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(450); + AddStep("Seek(401)", () => EditorClock.Seek(401)); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(400); + AddStep("Seek(401.999)", () => EditorClock.Seek(401.999)); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(400); } /// @@ -275,34 +282,37 @@ namespace osu.Game.Tests.Visual.Editing double lastTime = 0; - AddStep("Seek(0)", () => Clock.Seek(0)); + AddStep("Seek(0)", () => EditorClock.Seek(0)); + checkTime(0); for (int i = 0; i < 9; i++) { AddStep("SeekForward, Snap", () => { - lastTime = Clock.CurrentTime; - Clock.SeekForward(true); + lastTime = EditorClock.CurrentTime; + EditorClock.SeekForward(true); }); - AddAssert("Time > lastTime", () => Clock.CurrentTime > lastTime); + AddAssert("Time > lastTime", () => EditorClock.CurrentTime > lastTime); } for (int i = 0; i < 9; i++) { AddStep("SeekBackward, Snap", () => { - lastTime = Clock.CurrentTime; - Clock.SeekBackward(true); + lastTime = EditorClock.CurrentTime; + EditorClock.SeekBackward(true); }); - AddAssert("Time < lastTime", () => Clock.CurrentTime < lastTime); + AddAssert("Time < lastTime", () => EditorClock.CurrentTime < lastTime); } - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + checkTime(0); } + private void checkTime(double expectedTime) => AddUntilStep($"Current time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime)); + private void reset() { - AddStep("Reset", () => Clock.Seek(0)); + AddStep("Reset", () => EditorClock.Seek(0)); } private class TimingPointVisualiser : CompositeDrawable diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs index 6f248f1247..924396ce03 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs @@ -4,7 +4,6 @@ #nullable disable using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets; @@ -120,7 +119,7 @@ namespace osu.Game.Tests.Visual.Editing private void pressAndCheckTime(Key key, double expectedTime) { AddStep($"press {key}", () => InputManager.Key(key)); - AddUntilStep($"time is {expectedTime}", () => Precision.AlmostEquals(expectedTime, EditorClock.CurrentTime, 1)); + AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1)); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index adb495f3d3..58b5b41702 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -148,10 +148,6 @@ namespace osu.Game.Tests.Visual.Editing }); AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0); - - AddStep("place circle", () => InputManager.Click(MouseButton.Left)); - - AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1); } [Test] 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/Editing/TestSceneSelectionBlueprintDeselection.cs b/osu.Game.Tests/Visual/Editing/TestSceneSelectionBlueprintDeselection.cs new file mode 100644 index 0000000000..5c933468be --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneSelectionBlueprintDeselection.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneSelectionBlueprintDeselection : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestSingleDeleteAtSameTime() + { + HitCircle? circle1 = null; + + AddStep("add two circles at the same time", () => + { + EditorClock.Seek(0); + circle1 = new HitCircle(); + var circle2 = new HitCircle(); + + EditorBeatmap.Add(circle1); + EditorBeatmap.Add(circle2); + + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + AddStep("delete the first circle", () => EditorBeatmap.Remove(circle1)); + AddAssert("one hitobject remains", () => EditorBeatmap.HitObjects.Count == 1); + AddAssert("one hitobject selected", () => EditorBeatmap.SelectedHitObjects.Count == 1); + } + + [Test] + public void TestBigStackDeleteAtSameTime() + { + AddStep("add 20 circles at the same time", () => + { + EditorClock.Seek(0); + + for (int i = 0; i < 20; i++) + { + EditorBeatmap.Add(new HitCircle()); + } + }); + + AddStep("select half of the circles", () => + { + foreach (var hitObject in EditorBeatmap.HitObjects.SkipLast(10).Reverse()) + { + EditorBeatmap.SelectedHitObjects.Add(hitObject); + } + }); + + AddStep("delete all selected circles", () => + { + InputManager.PressKey(Key.Delete); + InputManager.ReleaseKey(Key.Delete); + }); + + AddAssert("10 hitobjects remain", () => EditorBeatmap.HitObjects.Count == 10); + AddAssert("no hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 0); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs index 7d881bc259..10e1206b53 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs @@ -113,7 +113,7 @@ namespace osu.Game.Tests.Visual.Editing .TriggerClick(); }); - AddUntilStep("wait for track playing", () => Clock.IsRunning); + AddUntilStep("wait for track playing", () => EditorClock.IsRunning); AddStep("click reset button", () => { @@ -122,7 +122,7 @@ namespace osu.Game.Tests.Visual.Editing .TriggerClick(); }); - AddUntilStep("wait for track stopped", () => !Clock.IsRunning); + AddUntilStep("wait for track stopped", () => !EditorClock.IsRunning); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs index d2984728b0..c098b683a6 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs @@ -17,7 +17,7 @@ namespace osu.Game.Tests.Visual.Editing protected override void LoadComplete() { base.LoadComplete(); - Clock.Seek(10000); + EditorClock.Seek(10000); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs index 7e0981ce69..54ad4e25e4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -29,16 +29,18 @@ namespace osu.Game.Tests.Visual.Editing private TimelineBlueprintContainer blueprintContainer => Editor.ChildrenOfType().First(); + private Vector2 getPosition(HitObject hitObject) => + blueprintContainer.SelectionBlueprints.First(s => s.Item == hitObject).ScreenSpaceDrawQuad.Centre; + + private Vector2 getMiddlePosition(HitObject hitObject1, HitObject hitObject2) => + (getPosition(hitObject1) + getPosition(hitObject2)) / 2; + private void moveMouseToObject(Func targetFunc) { AddStep("move mouse to object", () => { - var pos = blueprintContainer.SelectionBlueprints - .First(s => s.Item == targetFunc()) - .ChildrenOfType() - .First().ScreenSpaceDrawQuad.Centre; - - InputManager.MoveMouseTo(pos); + var hitObject = targetFunc(); + InputManager.MoveMouseTo(getPosition(hitObject)); }); } @@ -262,6 +264,56 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); } + [Test] + public void TestBasicDragSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 500, Position = new Vector2(100) }, + new HitCircle { StartTime = 1000, Position = new Vector2(200) }, + new HitCircle { StartTime = 1500, Position = new Vector2(300) }, + }; + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[0], addedObjects[1]))); + AddStep("mouse down", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("drag to select", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[2], addedObjects[3]))); + assertSelectionIs(new[] { addedObjects[1], addedObjects[2] }); + + AddStep("drag to deselect", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[1], addedObjects[2]))); + assertSelectionIs(new[] { addedObjects[1] }); + + AddStep("mouse up", () => InputManager.ReleaseButton(MouseButton.Left)); + assertSelectionIs(new[] { addedObjects[1] }); + } + + [Test] + public void TestFastDragSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 20000, Position = new Vector2(100) }, + new HitCircle { StartTime = 31000, Position = new Vector2(200) }, + new HitCircle { StartTime = 60000, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[0], addedObjects[1]))); + AddStep("mouse down", () => InputManager.PressButton(MouseButton.Left)); + AddStep("start drag", () => InputManager.MoveMouseTo(getPosition(addedObjects[1]))); + + AddStep("jump editor clock", () => EditorClock.Seek(30000)); + AddStep("jump editor clock", () => EditorClock.Seek(60000)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1)); + AddAssert("all blueprints are present", () => blueprintContainer.SelectionBlueprints.Count == EditorBeatmap.SelectedHitObjects.Count); + } + private void assertSelectionIs(IEnumerable hitObjects) => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects)); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs index 2cada1989e..11ac102814 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Utils; namespace osu.Game.Tests.Visual.Editing { @@ -14,16 +13,6 @@ namespace osu.Game.Tests.Visual.Editing public override Drawable CreateTestComponent() => Empty(); [Test] - [FlakyTest] - /* - * Fail rate around 0.3% - * - * TearDown : osu.Framework.Testing.Drawables.Steps.AssertButton+TracedException : range halved - * --TearDown - * at osu.Framework.Threading.ScheduledDelegate.RunTaskInternal() - * at osu.Framework.Threading.Scheduler.Update() - * at osu.Framework.Graphics.Drawable.UpdateSubTree() - */ public void TestVisibleRangeUpdatesOnZoomChange() { double initialVisibleRange = 0; @@ -32,12 +21,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("get initial range", () => initialVisibleRange = TimelineArea.Timeline.VisibleRange); AddStep("scale zoom", () => TimelineArea.Timeline.Zoom = 200); - AddAssert("range halved", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange / 2, 1)); + AddStep("range halved", () => Assert.That(TimelineArea.Timeline.VisibleRange, Is.EqualTo(initialVisibleRange / 2).Within(1))); AddStep("descale zoom", () => TimelineArea.Timeline.Zoom = 50); - AddAssert("range doubled", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange * 2, 1)); + AddStep("range doubled", () => Assert.That(TimelineArea.Timeline.VisibleRange, Is.EqualTo(initialVisibleRange * 2).Within(1))); AddStep("restore zoom", () => TimelineArea.Timeline.Zoom = 100); - AddAssert("range restored", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange, 1)); + AddStep("range restored", () => Assert.That(TimelineArea.Timeline.VisibleRange, Is.EqualTo(initialVisibleRange).Within(1))); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index e262bd756a..03c184c27d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.Editing [SetUpSteps] public void SetUpSteps() { - AddStep("Stop clock", () => Clock.Stop()); + AddStep("Stop clock", () => EditorClock.Stop()); AddUntilStep("wait for rows to load", () => Child.ChildrenOfType().Any()); } @@ -68,10 +68,10 @@ namespace osu.Game.Tests.Visual.Editing }); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); - AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 54670); - AddStep("Seek to just before next point", () => Clock.Seek(69000)); - AddStep("Start clock", () => Clock.Start()); + AddStep("Seek to just before next point", () => EditorClock.Seek(69000)); + AddStep("Start clock", () => EditorClock.Start()); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); } @@ -86,9 +86,9 @@ namespace osu.Game.Tests.Visual.Editing }); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); - AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 54670); - AddStep("Seek to later", () => Clock.Seek(80000)); + AddStep("Seek to later", () => EditorClock.Seek(80000)); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index 9dc403814b..89c5b9b23b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -46,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(30) }, - scrollContainer = new ZoomableScrollContainer + scrollContainer = new ZoomableScrollContainer(1, 60, 1) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -54,7 +55,7 @@ namespace osu.Game.Tests.Visual.Editing } } }, - new MenuCursor() + new MenuCursorContainer() }; scrollContainer.Add(innerBox = new Box @@ -66,6 +67,18 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Scroll container is loaded", () => scrollContainer.LoadState >= LoadState.Loaded); } + [Test] + public void TestInitialZoomOutOfRange() + { + AddStep("Invalid ZoomableScrollContainer throws ArgumentException", () => + { + Assert.Throws(() => + { + _ = new ZoomableScrollContainer(1, 60, 0); + }); + }); + } + [Test] public void TestWidthInitialization() { @@ -80,21 +93,6 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Inner container width matches scroll container", () => innerBox.DrawWidth == scrollContainer.DrawWidth); } - [Test] - public void TestZoomRangeUpdate() - { - AddStep("set zoom to 2", () => scrollContainer.Zoom = 2); - AddStep("set min zoom to 5", () => scrollContainer.MinZoom = 5); - AddAssert("zoom = 5", () => scrollContainer.Zoom == 5); - - AddStep("set max zoom to 10", () => scrollContainer.MaxZoom = 10); - AddAssert("zoom = 5", () => scrollContainer.Zoom == 5); - - AddStep("set min zoom to 20", () => scrollContainer.MinZoom = 20); - AddStep("set max zoom to 40", () => scrollContainer.MaxZoom = 40); - AddAssert("zoom = 20", () => scrollContainer.Zoom == 20); - } - [Test] public void TestZoom0() { diff --git a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs index 437f06c47f..c2b1ba3aba 100644 --- a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs +++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs @@ -3,18 +3,21 @@ #nullable disable +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Storyboards; using osuTK; using osuTK.Graphics; @@ -28,10 +31,14 @@ namespace osu.Game.Tests.Visual.Editing protected EditorBeatmap EditorBeatmap { get; private set; } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + [Resolved] + private AudioManager audio { get; set; } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new WaveformTestBeatmap(audio); + + protected override void LoadComplete() { - Beatmap.Value = new WaveformTestBeatmap(audio); + base.LoadComplete(); var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); EditorBeatmap = new EditorBeatmap(playable); @@ -39,7 +46,10 @@ namespace osu.Game.Tests.Visual.Editing Dependencies.Cache(EditorBeatmap); Dependencies.CacheAs(EditorBeatmap); - Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer().With(d => d.Alpha = 0); + Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer(); + Debug.Assert(Composer != null); + + Composer.Alpha = 0; Add(new OsuContextMenuContainer { @@ -68,11 +78,11 @@ namespace osu.Game.Tests.Visual.Editing }); } - protected override void LoadComplete() + [SetUpSteps] + public void SetUpSteps() { - base.LoadComplete(); - - Clock.Seek(2500); + AddUntilStep("wait for track loaded", () => MusicController.TrackLoaded); + AddStep("seek forward", () => EditorClock.Seek(2500)); } public abstract Drawable CreateTestComponent(); diff --git a/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs b/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs index 3ecf560eb1..ea4aa98f86 100644 --- a/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs +++ b/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs @@ -1,12 +1,11 @@ // 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.Graphics; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { @@ -19,7 +18,7 @@ namespace osu.Game.Tests.Visual.Gameplay { SetContents(skin => { - var implementation = skin != null + var implementation = skin is LegacySkin ? CreateLegacyImplementation() : CreateDefaultImplementation(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 47c8dc0f8d..f2fe55d719 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -31,20 +31,20 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void AddCheckSteps() { - // It doesn't matter which ruleset is used - this beatmap is only used for reference. - var beatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + // we only want this beatmap for time reference. + var referenceBeatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); AddUntilStep("score above zero", () => Player.ScoreProcessor.TotalScore.Value > 0); AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 2)); - seekTo(beatmap.Beatmap.Breaks[0].StartTime); + seekTo(referenceBeatmap.Breaks[0].StartTime); AddAssert("keys not counting", () => !Player.HUDOverlay.KeyCounter.IsCounting); AddAssert("overlay displays 100% accuracy", () => Player.BreakOverlay.ChildrenOfType().Single().AccuracyDisplay.Current.Value == 1); AddStep("rewind", () => Player.GameplayClockContainer.Seek(-80000)); AddUntilStep("key counter reset", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); - seekTo(beatmap.Beatmap.HitObjects[^1].GetEndTime()); + seekTo(referenceBeatmap.HitObjects[^1].GetEndTime()); AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true); AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index d4f3d0f390..01cc856a4a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestEmptyLegacyBeatmapSkinFallsBack() { - CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null)); + CreateSkinTest(TrianglesSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null)); AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value)); } @@ -80,14 +80,14 @@ namespace osu.Game.Tests.Visual.Gameplay (typeof(ScoreProcessor), actualComponentsContainer.Dependencies.Get()), (typeof(HealthProcessor), actualComponentsContainer.Dependencies.Get()), (typeof(GameplayState), actualComponentsContainer.Dependencies.Get()), - (typeof(GameplayClock), actualComponentsContainer.Dependencies.Get()) + (typeof(IGameplayClock), actualComponentsContainer.Dependencies.Get()) }, }; Add(expectedComponentsAdjustmentContainer); expectedComponentsAdjustmentContainer.UpdateSubTree(); var expectedInfo = expectedComponentsContainer.CreateSkinnableInfo(); - Remove(expectedComponentsAdjustmentContainer); + Remove(expectedComponentsAdjustmentContainer, true); return almostEqual(actualInfo, expectedInfo); } @@ -122,7 +122,7 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestOsuRuleset : OsuRuleset { - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(skin); + public override ISkin CreateSkinTransformer(ISkin skin, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(skin); private class TestOsuLegacySkinTransformer : OsuLegacySkinTransformer { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs new file mode 100644 index 0000000000..2dad5e2c32 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs @@ -0,0 +1,130 @@ +// 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 NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneClicksPerSecondCalculator : OsuTestScene + { + private ClicksPerSecondCalculator calculator = null!; + + private TestGameplayClock manualGameplayClock = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create components", () => + { + manualGameplayClock = new TestGameplayClock(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(IGameplayClock), manualGameplayClock) }, + Children = new Drawable[] + { + calculator = new ClicksPerSecondCalculator(), + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(ClicksPerSecondCalculator), calculator) }, + Child = new ClicksPerSecondCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(5), + } + } + }, + }; + }); + } + + [Test] + public void TestBasicConsistency() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + } + + [Test] + public void TestRateAdjustConsistency() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + AddStep("set rate 0.5x", () => manualGameplayClock.TrueGameplayRate = 0.5); + checkClicksPerSecondValue(5); + } + + [Test] + public void TestInputsDiscardedOnRewind() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + seek(500); + checkClicksPerSecondValue(6); + seek(1000); + checkClicksPerSecondValue(6); + } + + private void checkClicksPerSecondValue(int i) => AddAssert("clicks/s is correct", () => calculator.Value, () => Is.EqualTo(i)); + + private void seekClockImmediately(double time) => manualGameplayClock.CurrentTime = time; + + private void seek(double time) => AddStep($"Seek to {time}ms", () => seekClockImmediately(time)); + + private void addInputs(IEnumerable inputs) + { + double baseTime = manualGameplayClock.CurrentTime; + + foreach (double timestamp in inputs) + { + seekClockImmediately(timestamp); + calculator.AddInputTimestamp(); + } + + seekClockImmediately(baseTime); + } + + private class TestGameplayClock : IGameplayClock + { + public double CurrentTime { get; set; } + + public double Rate => 1; + + public bool IsRunning => true; + + public double TrueGameplayRate { set => adjustableAudioComponent.Tempo.Value = value; } + + private readonly AudioAdjustments adjustableAudioComponent = new AudioAdjustments(); + + public void ProcessFrame() + { + } + + public double ElapsedFrameTime => throw new NotImplementedException(); + public double FramesPerSecond => throw new NotImplementedException(); + public FrameTimeInfo TimeInfo => throw new NotImplementedException(); + public double StartTime => throw new NotImplementedException(); + + public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent; + + public IEnumerable NonGameplayAdjustments => throw new NotImplementedException(); + public IBindable IsPaused => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneColourHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneColourHitErrorMeter.cs new file mode 100644 index 0000000000..a953db4f19 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneColourHitErrorMeter.cs @@ -0,0 +1,117 @@ +// 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.Diagnostics; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneColourHitErrorMeter : OsuTestScene + { + private DependencyProvidingContainer dependencyContainer = null!; + + private readonly Bindable lastJudgementResult = new Bindable(); + private ScoreProcessor scoreProcessor = null!; + + private int iteration; + + private ColourHitErrorMeter colourHitErrorMeter = null!; + + public TestSceneColourHitErrorMeter() + { + AddSliderStep("Judgement spacing", 0, 10, 2, spacing => + { + if (colourHitErrorMeter.IsNotNull()) + colourHitErrorMeter.JudgementSpacing.Value = spacing; + }); + + AddSliderStep("Judgement count", 1, 50, 5, spacing => + { + if (colourHitErrorMeter.IsNotNull()) + colourHitErrorMeter.JudgementCount.Value = spacing; + }); + } + + [SetUpSteps] + public void SetupSteps() => AddStep("Create components", () => + { + var ruleset = CreateRuleset(); + + Debug.Assert(ruleset != null); + + scoreProcessor = new ScoreProcessor(ruleset); + Child = dependencyContainer = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(ScoreProcessor), scoreProcessor) + } + }; + dependencyContainer.Child = colourHitErrorMeter = new ColourHitErrorMeter + { + Margin = new MarginPadding + { + Top = 100 + }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(2), + }; + }); + + protected override Ruleset CreateRuleset() => new OsuRuleset(); + + [Test] + public void TestSpacingChange() + { + AddRepeatStep("Add judgement", applyOneJudgement, 5); + AddStep("Change spacing", () => colourHitErrorMeter.JudgementSpacing.Value = 10); + AddRepeatStep("Add judgement", applyOneJudgement, 5); + } + + [Test] + public void TestJudgementAmountChange() + { + AddRepeatStep("Add judgement", applyOneJudgement, 10); + AddStep("Judgement count change to 4", () => colourHitErrorMeter.JudgementCount.Value = 4); + AddRepeatStep("Add judgement", applyOneJudgement, 8); + } + + [Test] + public void TestHitErrorShapeChange() + { + AddRepeatStep("Add judgement", applyOneJudgement, 8); + AddStep("Change shape square", () => colourHitErrorMeter.JudgementShape.Value = ColourHitErrorMeter.ShapeStyle.Square); + AddRepeatStep("Add judgement", applyOneJudgement, 10); + AddStep("Change shape circle", () => colourHitErrorMeter.JudgementShape.Value = ColourHitErrorMeter.ShapeStyle.Circle); + } + + private void applyOneJudgement() + { + lastJudgementResult.Value = new OsuJudgementResult(new HitObject + { + StartTime = iteration * 10000, + }, new OsuJudgement()) + { + Type = HitResult.Great, + }; + scoreProcessor.ApplyResult(lastJudgementResult.Value); + + iteration++; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index a79ba0ae5d..334d8f1452 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -263,27 +263,30 @@ namespace osu.Game.Tests.Visual.Gameplay return beatmap; } - private void createTest(IBeatmap beatmap, Action overrideAction = null) => AddStep("create test", () => + private void createTest(IBeatmap beatmap, Action overrideAction = null) { - var ruleset = new TestScrollingRuleset(); - - drawableRuleset = (TestDrawableScrollingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); - drawableRuleset.FrameStablePlayback = false; - - overrideAction?.Invoke(drawableRuleset); - - Child = new Container + AddStep("create test", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Height = 0.75f, - Width = 400, - Masking = true, - Clock = new FramedClock(testClock), - Child = drawableRuleset - }; - }); + var ruleset = new TestScrollingRuleset(); + + drawableRuleset = (TestDrawableScrollingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); + drawableRuleset.FrameStablePlayback = false; + + overrideAction?.Invoke(drawableRuleset); + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Height = 0.75f, + Width = 400, + Masking = true, + Clock = new FramedClock(testClock), + Child = drawableRuleset + }; + }); + } #region Ruleset diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index ca7d7b42d8..3fc456c411 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.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 NUnit.Framework; @@ -73,6 +71,18 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); } + [Test] + public void TestZeroScale() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddAssert("sprites present", () => sprites.All(s => s.IsPresent)); + AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(0, 1))); + AddAssert("sprites not present", () => sprites.All(s => !s.IsPresent)); + } + [Test] public void TestNegativeScale() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index 97ffbfc796..ef74024b4b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -137,13 +137,13 @@ namespace osu.Game.Tests.Visual.Gameplay private void seekManualTo(double time) => AddStep($"seek manual clock to {time}", () => manualClock.CurrentTime = time); - private void confirmSeek(double time) => AddUntilStep($"wait for seek to {time}", () => consumer.Clock.CurrentTime == time); + private void confirmSeek(double time) => AddUntilStep($"wait for seek to {time}", () => consumer.Clock.CurrentTime, () => Is.EqualTo(time)); private void checkFrameCount(int frames) => - AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames); + AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames, () => Is.EqualTo(frames)); private void checkRate(double rate) => - AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate); + AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate, () => Is.EqualTo(rate)); public class ClockConsumingChild : CompositeDrawable { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 663e398c01..171ae829a9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -6,7 +6,9 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Extensions.PolygonExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; @@ -18,37 +20,62 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneGameplayLeaderboard : OsuTestScene { - private readonly TestGameplayLeaderboard leaderboard; + private TestGameplayLeaderboard leaderboard; private readonly BindableDouble playerScore = new BindableDouble(); public TestSceneGameplayLeaderboard() { - Add(leaderboard = new TestGameplayLeaderboard + AddStep("toggle expanded", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(2), + if (leaderboard != null) + leaderboard.Expanded.Value = !leaderboard.Expanded.Value; }); + + AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); } - [SetUpSteps] - public void SetUpSteps() + [Test] + public void TestLayoutWithManyScores() { - AddStep("reset leaderboard", () => + createLeaderboard(); + + AddStep("add many scores in one go", () => { - leaderboard.Clear(); - playerScore.Value = 1222333; + for (int i = 0; i < 32; i++) + createRandomScore(new APIUser { Username = $"Player {i + 1}" }); + + // Add player at end to force an animation down the whole list. + playerScore.Value = 0; + createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); }); - AddStep("add local player", () => createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true)); - AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); - AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); + // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration + // has caused layout to not work in the past. + + AddUntilStep("wait for fill flow layout", + () => leaderboard.ChildrenOfType>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad)); + + AddUntilStep("wait for some scores not masked away", + () => leaderboard.ChildrenOfType().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre))); + + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + + AddStep("change score to middle", () => playerScore.Value = 1000000); + AddWaitStep("wait for movement", 5); + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + + AddStep("change score to first", () => playerScore.Value = 5000000); + AddWaitStep("wait for movement", 5); + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); } [Test] public void TestPlayerScore() { + createLeaderboard(); + addLocalPlayer(); + var player2Score = new BindableDouble(1234567); var player3Score = new BindableDouble(1111111); @@ -73,6 +100,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestRandomScores() { + createLeaderboard(); + addLocalPlayer(); + int playerNumber = 1; AddRepeatStep("add player with random score", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 10); } @@ -80,6 +110,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestExistingUsers() { + createLeaderboard(); + addLocalPlayer(); + AddStep("add peppy", () => createRandomScore(new APIUser { Username = "peppy", Id = 2 })); AddStep("add smoogipoo", () => createRandomScore(new APIUser { Username = "smoogipoo", Id = 1040328 })); AddStep("add flyte", () => createRandomScore(new APIUser { Username = "flyte", Id = 3103765 })); @@ -89,6 +122,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMaxHeight() { + createLeaderboard(); + addLocalPlayer(); + int playerNumber = 1; AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3); checkHeight(4); @@ -103,6 +139,28 @@ namespace osu.Game.Tests.Visual.Gameplay => AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (GameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount); } + private void addLocalPlayer() + { + AddStep("add local player", () => + { + playerScore.Value = 1222333; + createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); + }); + } + + private void createLeaderboard() + { + AddStep("create leaderboard", () => + { + Child = leaderboard = new TestGameplayLeaderboard + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2), + }; + }); + } + private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableDouble(RNG.Next(0, 5_000_000)), user); private void createLeaderboardScore(BindableDouble score, APIUser user, bool isTracked = false) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs index 0a32513834..bd55ed8bd6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); addSeekStep(3000); AddAssert("all judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => h.Judged)); - AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses >= 7)); + AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.Select(kc => kc.CountPresses).Sum() == 15); AddStep("clear results", () => Player.Results.Clear()); addSeekStep(0); AddAssert("none judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => !h.Judged)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index f1084bca5f..1fe2dfd4df 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.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 NUnit.Framework; @@ -21,22 +19,22 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestAllSamplesStopDuringSeek() { - DrawableSlider slider = null; - PoolableSkinnableSample[] samples = null; - ISamplePlaybackDisabler sampleDisabler = null; + DrawableSlider? slider = null; + PoolableSkinnableSample[] samples = null!; + ISamplePlaybackDisabler sampleDisabler = null!; AddUntilStep("get variables", () => { sampleDisabler = Player; slider = Player.ChildrenOfType().MinBy(s => s.HitObject.StartTime); - samples = slider?.ChildrenOfType().ToArray(); + samples = slider.ChildrenOfType().ToArray(); return slider != null; }); AddUntilStep("wait for slider sliding then seek", () => { - if (!slider.Tracking.Value) + if (slider?.Tracking.Value != true) return false; if (!samples.Any(s => s.Playing)) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index 79e3cf000e..b6da562bd0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -103,7 +103,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("hit first hitobject", () => { InputManager.Click(MouseButton.Left); - return nextObjectEntry.Result.HasResult; + return nextObjectEntry.Result?.HasResult == true; }); AddAssert("check correct object after hit", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[1]); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index dd0f965914..a984f508ea 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -16,6 +16,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; @@ -38,8 +39,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); - [Cached] - private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + [Cached(typeof(IGameplayClock))] + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; @@ -148,6 +149,42 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); } + [Test] + public void TestInputDoesntWorkWhenHUDHidden() + { + SongProgressBar getSongProgress() => hudOverlay.ChildrenOfType().Single(); + + bool seeked = false; + + createNew(); + + AddStep("bind seek", () => + { + seeked = false; + + var progress = getSongProgress(); + + progress.ShowHandle = true; + progress.OnSeek += _ => seeked = true; + }); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + + AddStep("attempt seek", () => + { + InputManager.MoveMouseTo(getSongProgress()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("seek not performed", () => !seeked); + + AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + + AddStep("attempt seek", () => InputManager.Click(MouseButton.Left)); + AddAssert("seek performed", () => seeked); + } + [Test] public void TestHiddenHUDDoesntBlockComponentUpdates() { @@ -159,6 +196,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for hud load", () => hudOverlay.IsLoaded); AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); + AddUntilStep("wait for hud load", () => hudOverlay.ChildrenOfType().All(c => c.ComponentsLoaded)); AddStep("bind on update", () => { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 707f807e64..7c668adba5 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -107,13 +107,13 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("no bars added", () => !this.ChildrenOfType().Any()); AddAssert("circle added", () => this.ChildrenOfType().All( - meter => meter.ChildrenOfType().Count() == 1)); + meter => meter.ChildrenOfType().Count() == 1)); AddStep("miss", () => newJudgement(50, HitResult.Miss)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); AddAssert("circle added", () => this.ChildrenOfType().All( - meter => meter.ChildrenOfType().Count() == 2)); + meter => meter.ChildrenOfType().Count() == 2)); } [Test] @@ -123,11 +123,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("small bonus", () => newJudgement(result: HitResult.SmallBonus)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); AddStep("large bonus", () => newJudgement(result: HitResult.LargeBonus)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); } [Test] @@ -137,16 +137,17 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("ignore hit", () => newJudgement(result: HitResult.IgnoreHit)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); AddStep("ignore miss", () => newJudgement(result: HitResult.IgnoreMiss)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); } [Test] public void TestProcessingWhileHidden() { + const int max_displayed_judgements = 20; AddStep("OD 1", () => recreateDisplay(new OsuHitWindows(), 1)); AddStep("hide displays", () => @@ -155,16 +156,16 @@ namespace osu.Game.Tests.Visual.Gameplay hitErrorMeter.Hide(); }); - AddRepeatStep("hit", () => newJudgement(), ColourHitErrorMeter.MAX_DISPLAYED_JUDGEMENTS * 2); + AddRepeatStep("hit", () => newJudgement(), max_displayed_judgements * 2); AddAssert("bars added", () => this.ChildrenOfType().Any()); - AddAssert("circle added", () => this.ChildrenOfType().Any()); + AddAssert("circle added", () => this.ChildrenOfType().Any()); AddUntilStep("wait for bars to disappear", () => !this.ChildrenOfType().Any()); AddUntilStep("ensure max circles not exceeded", () => { return this.ChildrenOfType() - .All(m => m.ChildrenOfType().Count() <= ColourHitErrorMeter.MAX_DISPLAYED_JUDGEMENTS); + .All(m => m.ChildrenOfType().Count() <= max_displayed_judgements); }); AddStep("show displays", () => @@ -183,12 +184,12 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("bar added", () => this.ChildrenOfType().All( meter => meter.ChildrenOfType().Count() == 1)); AddAssert("circle added", () => this.ChildrenOfType().All( - meter => meter.ChildrenOfType().Count() == 1)); + meter => meter.ChildrenOfType().Count() == 1)); AddStep("clear", () => this.ChildrenOfType().ForEach(meter => meter.Clear())); AddAssert("bar cleared", () => !this.ChildrenOfType().Any()); - AddAssert("colour cleared", () => !this.ChildrenOfType().Any()); + AddAssert("colour cleared", () => !this.ChildrenOfType().Any()); } private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 7f4276f819..c18a78fe3c 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,9 +17,9 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneLeadIn : RateAdjustedBeatmapTestScene { - private LeadInPlayer player; + private LeadInPlayer player = null!; - private const double lenience_ms = 10; + private const double lenience_ms = 100; private const double first_hit_object = 2170; @@ -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)] @@ -76,18 +66,20 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(-10000, -10000, true)] public void TestStoryboardProducesCorrectStartTimeFadeInAfterOtherEvents(double firstStoryboardEvent, double expectedStartTime, bool addEventToLoop) { + const double loop_start_time = -20000; + var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); // these should be ignored as we have an alpha visibility blocker proceeding this command. - sprite.TimelineGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); - var loopGroup = sprite.AddLoop(-20000, 50); - loopGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); + sprite.TimelineGroup.Scale.Add(Easing.None, loop_start_time, -18000, 0, 1); + var loopGroup = sprite.AddLoop(loop_start_time, 50); + loopGroup.Scale.Add(Easing.None, loop_start_time, -18000, 0, 1); var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; - double targetTime = addEventToLoop ? 20000 : 0; - target.Alpha.Add(Easing.None, targetTime + firstStoryboardEvent, targetTime + firstStoryboardEvent + 500, 0, 1); + double loopRelativeOffset = addEventToLoop ? -loop_start_time : 0; + target.Alpha.Add(Easing.None, loopRelativeOffset + firstStoryboardEvent, loopRelativeOffset + firstStoryboardEvent + 500, 0, 1); // these should be ignored due to being in the future. sprite.TimelineGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); @@ -97,14 +89,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,19 +117,15 @@ 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.GameplayClock.CurrentTime; - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); if (!FirstFrameClockTime.HasValue) { - FirstFrameClockTime = GameplayClockContainer.GameplayClock.CurrentTime; + FirstFrameClockTime = GameplayClockContainer.CurrentTime; AddInternal(new OsuSpriteText { Text = $"GameplayStartTime: {DrawableRuleset.GameplayStartTime} " diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneModValidity.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneModValidity.cs new file mode 100644 index 0000000000..0c6b656ab6 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneModValidity.cs @@ -0,0 +1,50 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public class TestSceneModValidity : TestSceneAllRulesetPlayers + { + protected override void AddCheckSteps() + { + AddStep("Check all mod acronyms are unique", () => + { + var mods = Ruleset.Value.CreateInstance().AllMods; + + IEnumerable acronyms = mods.Select(m => m.Acronym); + + Assert.That(acronyms, Is.Unique); + }); + + AddStep("Check all mods are two-way incompatible", () => + { + var mods = Ruleset.Value.CreateInstance().AllMods; + + IEnumerable modInstances = mods.Select(mod => mod.CreateInstance()); + + foreach (var modToCheck in modInstances) + { + var incompatibleMods = modToCheck.IncompatibleMods; + + foreach (var incompatible in incompatibleMods) + { + foreach (var incompatibleMod in modInstances.Where(m => incompatible.IsInstanceOfType(m))) + { + Assert.That( + incompatibleMod.IncompatibleMods.Any(m => m.IsInstanceOfType(modToCheck)), + $"{modToCheck} has {incompatibleMod} in it's incompatible mods, but {incompatibleMod} does not have {modToCheck} in it's incompatible mods." + ); + } + } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs index 3e637f1870..789e7e770f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay base.SetUpSteps(); AddUntilStep("gameplay has started", - () => Player.GameplayClockContainer.GameplayClock.CurrentTime > Player.DrawableRuleset.GameplayStartTime); + () => Player.GameplayClockContainer.CurrentTime > Player.DrawableRuleset.GameplayStartTime); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs index 26706d9465..66441c8bad 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Visual.Gameplay } private TestParticleSpewer createSpewer() => - new TestParticleSpewer(skinManager.DefaultLegacySkin.GetTexture("star2")) + new TestParticleSpewer(skinManager.DefaultClassicSkin.GetTexture("star2")) { Origin = Anchor.Centre, RelativePositionAxes = Axes.Both, diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 5bd0a29308..d0371acce7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.Gameplay public TestScenePause() { - base.Content.Add(content = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }); + base.Content.Add(content = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }); } [SetUpSteps] @@ -66,7 +66,7 @@ namespace osu.Game.Tests.Visual.Gameplay Player.OnUpdate += _ => { double currentTime = Player.GameplayClockContainer.CurrentTime; - alwaysGoingForward &= currentTime >= lastTime; + alwaysGoingForward &= currentTime >= lastTime - 500; lastTime = currentTime; }; }); @@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Gameplay resumeAndConfirm(); - AddAssert("time didn't go backwards", () => alwaysGoingForward); + AddAssert("time didn't go too far backwards", () => alwaysGoingForward); AddStep("reset offset", () => LocalConfig.SetValue(OsuSetting.AudioOffset, 0.0)); } @@ -90,6 +90,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("player not playing", () => !Player.LocalUserPlaying.Value); resumeAndConfirm(); + + AddAssert("Resumed without seeking forward", () => Player.LastResumeTime, () => Is.LessThanOrEqualTo(Player.LastPauseTime)); + AddUntilStep("player playing", () => Player.LocalUserPlaying.Value); } @@ -313,7 +316,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("pause again", () => { Player.Pause(); - return !Player.GameplayClockContainer.GameplayClock.IsRunning; + return !Player.GameplayClockContainer.IsRunning; }); AddAssert("loop is playing", () => getLoop().IsPlaying); @@ -367,7 +370,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void confirmNoTrackAdjustments() { - AddAssert("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value == 1); + AddUntilStep("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value, () => Is.EqualTo(1)); } private void restart() => AddStep("restart", () => Player.Restart()); @@ -378,7 +381,16 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("pause overlay " + (isShown ? "shown" : "hidden"), () => Player.PauseOverlayVisible == isShown); private void confirmClockRunning(bool isRunning) => - AddUntilStep("clock " + (isRunning ? "running" : "stopped"), () => Player.GameplayClockContainer.GameplayClock.IsRunning == isRunning); + AddUntilStep("clock " + (isRunning ? "running" : "stopped"), () => + { + bool completed = Player.GameplayClockContainer.IsRunning == isRunning; + + if (completed) + { + } + + return completed; + }); protected override bool AllowFail => true; @@ -386,6 +398,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected class PausePlayer : TestPlayer { + public double LastPauseTime { get; private set; } + public double LastResumeTime { get; private set; } + public bool FailOverlayVisible => FailOverlay.State.Value == Visibility.Visible; public bool PauseOverlayVisible => PauseOverlay.State.Value == Visibility.Visible; @@ -399,6 +414,23 @@ namespace osu.Game.Tests.Visual.Gameplay base.OnEntering(e); GameplayClockContainer.Stop(); } + + private bool? isRunning; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (GameplayClockContainer.IsRunning != isRunning) + { + isRunning = GameplayClockContainer.IsRunning; + + if (isRunning.Value) + LastResumeTime = GameplayClockContainer.CurrentTime; + else + LastPauseTime = GameplayClockContainer.CurrentTime; + } + } } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 56588e4d4e..6b24ac7384 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -13,11 +13,11 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Utils; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; @@ -56,6 +56,10 @@ namespace osu.Game.Tests.Visual.Gameplay private readonly ChangelogOverlay changelogOverlay; + private double savedTrackVolume; + private double savedMasterVolume; + private bool savedMutedState; + public TestScenePlayerLoader() { AddRange(new Drawable[] @@ -75,11 +79,21 @@ namespace osu.Game.Tests.Visual.Gameplay } [SetUp] - public void Setup() => Schedule(() => + public void Setup() => Schedule(() => player = null); + + [SetUpSteps] + public override void SetUpSteps() { - player = null; - audioManager.Volume.SetDefault(); - }); + base.SetUpSteps(); + + AddStep("read all notifications", () => + { + notificationOverlay.Show(); + notificationOverlay.Hide(); + }); + + AddUntilStep("wait for no notifications", () => notificationOverlay.UnreadCount.Value, () => Is.EqualTo(0)); + } /// /// Sets the input manager child to a new test player loader container instance. @@ -98,7 +112,13 @@ namespace osu.Game.Tests.Visual.Gameplay private void prepareBeatmap() { var workingBeatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + + // Add intro time to test quick retry skipping (TestQuickRetry). + workingBeatmap.BeatmapInfo.AudioLeadIn = 60000; + + // Turn on epilepsy warning to test warning display (TestEpilepsyWarning). workingBeatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning; + Beatmap.Value = workingBeatmap; foreach (var mod in SelectedMods.Value.OfType()) @@ -147,6 +167,7 @@ namespace osu.Game.Tests.Visual.Gameplay moveMouse(); return player?.LoadState == LoadState.Ready; }); + AddRepeatStep("move mouse", moveMouse, 20); AddAssert("loader still active", () => loader.IsCurrentScreen()); @@ -154,6 +175,8 @@ namespace osu.Game.Tests.Visual.Gameplay void moveMouse() { + notificationOverlay.State.Value = Visibility.Hidden; + InputManager.MoveMouseTo( loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft) @@ -241,13 +264,13 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMutedNotificationMasterVolume() { - addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, () => audioManager.Volume.IsDefault); + addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, () => audioManager.Volume.Value == 0.5); } [Test] public void TestMutedNotificationTrackVolume() { - addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, () => audioManager.VolumeTrack.IsDefault); + addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, () => audioManager.VolumeTrack.Value == 0.5); } [Test] @@ -274,19 +297,16 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load player", () => resetPlayer(false, beforeLoad)); AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); - AddAssert("check for notification", () => notificationOverlay.UnreadCount.Value == 1); - AddStep("click notification", () => - { - var scrollContainer = (OsuScrollContainer)notificationOverlay.Children.Last(); - var flowContainer = scrollContainer.Children.OfType>().First(); - var notification = flowContainer.First(); + saveVolumes(); - InputManager.MoveMouseTo(notification); - InputManager.Click(MouseButton.Left); - }); + AddAssert("check for notification", () => notificationOverlay.UnreadCount.Value, () => Is.EqualTo(1)); + + clickNotification(); AddAssert("check " + volumeName, assert); + restoreVolumes(); + AddUntilStep("wait for player load", () => player.IsLoaded); } @@ -294,6 +314,9 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(false)] public void TestEpilepsyWarning(bool warning) { + saveVolumes(); + setFullVolume(); + AddStep("change epilepsy warning", () => epilepsyWarning = warning); AddStep("load dummy beatmap", () => resetPlayer(false)); @@ -306,38 +329,16 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("sound volume decreased", () => Beatmap.Value.Track.AggregateVolume.Value == 0.25); AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); } - } - [TestCase(false, 1.0, false)] // not charging, above cutoff --> no warning - [TestCase(true, 0.1, false)] // charging, below cutoff --> no warning - [TestCase(false, 0.25, true)] // not charging, at cutoff --> warning - public void TestLowBatteryNotification(bool isCharging, double chargeLevel, bool shouldWarn) - { - AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).Value = false); - - // set charge status and level - AddStep("load player", () => resetPlayer(false, () => - { - batteryInfo.SetCharging(isCharging); - batteryInfo.SetChargeLevel(chargeLevel); - })); - AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); - AddAssert($"notification {(shouldWarn ? "triggered" : "not triggered")}", () => notificationOverlay.UnreadCount.Value == (shouldWarn ? 1 : 0)); - AddStep("click notification", () => - { - var scrollContainer = (OsuScrollContainer)notificationOverlay.Children.Last(); - var flowContainer = scrollContainer.Children.OfType>().First(); - var notification = flowContainer.First(); - - InputManager.MoveMouseTo(notification); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("wait for player load", () => player.IsLoaded); + restoreVolumes(); } [Test] public void TestEpilepsyWarningEarlyExit() { + saveVolumes(); + setFullVolume(); + AddStep("set epilepsy warning", () => epilepsyWarning = true); AddStep("load dummy beatmap", () => resetPlayer(false)); @@ -350,6 +351,102 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("warning is hidden", () => getWarning().State.Value == Visibility.Hidden); AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); + + restoreVolumes(); + } + + [TestCase(true, 1.0, false)] // on battery, above cutoff --> no warning + [TestCase(false, 0.1, false)] // not on battery, below cutoff --> no warning + [TestCase(true, 0.25, true)] // on battery, at cutoff --> warning + [TestCase(true, null, false)] // on battery, level unknown --> no warning + public void TestLowBatteryNotification(bool onBattery, double? chargeLevel, bool shouldWarn) + { + AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).Value = false); + + // set charge status and level + AddStep("load player", () => resetPlayer(false, () => + { + batteryInfo.SetOnBattery(onBattery); + batteryInfo.SetChargeLevel(chargeLevel); + })); + AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); + + if (shouldWarn) + clickNotification(); + else + AddAssert("notification not triggered", () => notificationOverlay.UnreadCount.Value == 0); + + AddUntilStep("wait for player load", () => player.IsLoaded); + } + + private void restoreVolumes() + { + AddStep("restore previous volumes", () => + { + audioManager.VolumeTrack.Value = savedTrackVolume; + audioManager.Volume.Value = savedMasterVolume; + volumeOverlay.IsMuted.Value = savedMutedState; + }); + } + + private void setFullVolume() + { + AddStep("set volumes to 100%", () => + { + audioManager.VolumeTrack.Value = 1; + audioManager.Volume.Value = 1; + volumeOverlay.IsMuted.Value = false; + }); + } + + private void saveVolumes() + { + AddStep("save previous volumes", () => + { + savedTrackVolume = audioManager.VolumeTrack.Value; + savedMasterVolume = audioManager.Volume.Value; + savedMutedState = volumeOverlay.IsMuted.Value; + }); + } + + [Test] + public void TestQuickRetry() + { + TestPlayer getCurrentPlayer() => loader.CurrentPlayer as TestPlayer; + bool checkSkipButtonVisible() => player.ChildrenOfType().FirstOrDefault()?.IsButtonVisible == true; + + TestPlayer previousPlayer = null; + + AddStep("load dummy beatmap", () => resetPlayer(false)); + + AddUntilStep("wait for current", () => getCurrentPlayer()?.IsCurrentScreen() == true); + AddStep("store previous player", () => previousPlayer = getCurrentPlayer()); + + AddStep("Restart map normally", () => getCurrentPlayer().Restart()); + AddUntilStep("wait for load", () => getCurrentPlayer()?.LoadedBeatmapSuccessfully == true); + + AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer); + AddStep("store previous player", () => previousPlayer = getCurrentPlayer()); + + AddUntilStep("skip button visible", checkSkipButtonVisible); + + AddStep("press quick retry key", () => InputManager.PressKey(Key.Tilde)); + AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer); + AddStep("release quick retry key", () => InputManager.ReleaseKey(Key.Tilde)); + + AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState == LoadState.Ready); + + AddUntilStep("time reached zero", () => getCurrentPlayer()?.GameplayClockContainer.CurrentTime > 0); + AddUntilStep("skip button not visible", () => !checkSkipButtonVisible()); + } + + private void clickNotification() + { + Notification notification = null; + + AddUntilStep("wait for notification", () => (notification = notificationOverlay.ChildrenOfType().FirstOrDefault()) != null); + AddStep("open notification overlay", () => notificationOverlay.Show()); + AddStep("click notification", () => notification.TriggerClick()); } private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(); @@ -373,7 +470,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; } @@ -408,19 +505,19 @@ namespace osu.Game.Tests.Visual.Gameplay /// private class LocalBatteryInfo : BatteryInfo { - private bool isCharging = true; - private double chargeLevel = 1; + private bool onBattery; + private double? chargeLevel; - public override bool IsCharging => isCharging; + public override bool OnBattery => onBattery; - public override double ChargeLevel => chargeLevel; + public override double? ChargeLevel => chargeLevel; - public void SetCharging(bool value) + public void SetOnBattery(bool value) { - isCharging = value; + onBattery = value; } - public void SetChargeLevel(double value) + public void SetChargeLevel(double? value) { chargeLevel = value; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index 3011515b62..38a091dd85 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -1,20 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Tests.Resources; @@ -31,8 +38,8 @@ namespace osu.Game.Tests.Visual.Gameplay private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, Scheduler)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, API)); Dependencies.Cache(Realm); } @@ -57,13 +64,79 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool HasCustomSteps => true; - protected override bool AllowFail => false; + protected override bool AllowFail => allowFail; + + private bool allowFail; + + [SetUp] + public void SetUp() + { + allowFail = false; + customRuleset = null; + } + + [Test] + public void TestSaveFailedReplay() + { + AddStep("allow fail", () => allowFail = true); + + CreateTest(); + + AddUntilStep("fail screen displayed", () => Player.ChildrenOfType().First().State.Value == Visibility.Visible); + AddUntilStep("wait for button clickable", () => Player.ChildrenOfType().First().ChildrenOfType().First().Enabled.Value); + + AddUntilStep("score not in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) == null)); + AddStep("click save button", () => Player.ChildrenOfType().First().ChildrenOfType().First().TriggerClick()); + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + } + + [Test] + public void TestLastPlayedUpdated() + { + DateTimeOffset? getLastPlayed() => Realm.Run(r => r.Find(Beatmap.Value.BeatmapInfo.ID)?.LastPlayed); + + AddAssert("last played is null", () => getLastPlayed() == null); + + CreateTest(); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddUntilStep("wait for last played to update", () => getLastPlayed() != null); + } + + [Test] + public void TestModReferenceNotRetained() + { + AddStep("allow fail", () => allowFail = false); + + Mod[] originalMods = { new OsuModDaycore { SpeedChange = { Value = 0.8 } } }; + Mod[] playerMods = null!; + + AddStep("load player with mods", () => LoadPlayer(originalMods)); + AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); + + AddStep("get mods at start of gameplay", () => playerMods = Player.Score.ScoreInfo.Mods.ToArray()); + + // Player creates new instance of mods during load. + AddAssert("player score has copied mods", () => playerMods.First(), () => Is.Not.SameAs(originalMods.First())); + AddAssert("player score has matching mods", () => playerMods.First(), () => Is.EqualTo(originalMods.First())); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + + // Player creates new instance of mods after gameplay to ensure any runtime references to drawables etc. are not retained. + AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.Not.SameAs(playerMods.First())); + AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First())); + + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID)).Mods.First(), () => Is.EqualTo(playerMods.First())); + } [Test] public void TestScoreStoredLocally() { - AddStep("set no custom ruleset", () => customRuleset = null); - CreateTest(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index 96efca6b65..d1bdfb1dfa 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -239,7 +239,7 @@ namespace osu.Game.Tests.Visual.Gameplay createPlayerTest(false, r => { var beatmap = createTestBeatmap(r); - beatmap.BeatmapInfo.OnlineID = -1; + beatmap.BeatmapInfo.ResetOnlineInfo(); return beatmap; }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index 1fa4885b7a..618ffbcb0e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -158,21 +158,24 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("clean up", () => drawableRuleset.NewResult -= onNewResult); } - private void createTest(IBeatmap beatmap, int poolSize, Func createClock = null) => AddStep("create test", () => + private void createTest(IBeatmap beatmap, int poolSize, Func createClock = null) { - var ruleset = new TestPoolingRuleset(); - - drawableRuleset = (TestDrawablePoolingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); - drawableRuleset.FrameStablePlayback = true; - drawableRuleset.PoolSize = poolSize; - - Child = new Container + AddStep("create test", () => { - RelativeSizeAxes = Axes.Both, - Clock = createClock?.Invoke() ?? new FramedOffsetClock(Clock, false) { Offset = -Clock.CurrentTime }, - Child = drawableRuleset - }; - }); + var ruleset = new TestPoolingRuleset(); + + drawableRuleset = (TestDrawablePoolingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); + drawableRuleset.FrameStablePlayback = true; + drawableRuleset.PoolSize = poolSize; + + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Clock = createClock?.Invoke() ?? new FramedOffsetClock(Clock, false) { Offset = -Clock.CurrentTime }, + Child = drawableRuleset + }; + }); + } #region Ruleset diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index c259d5f0a8..cd227630c1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -7,7 +7,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online; -using osu.Game.Online.API.Requests.Responses; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -15,7 +14,6 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -30,9 +28,6 @@ namespace osu.Game.Tests.Visual.Gameplay { private const long online_score_id = 2553163309; - [Resolved] - private RulesetStore rulesets { get; set; } - private TestReplayDownloadButton downloadButton; [Resolved] @@ -207,25 +202,22 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for load", () => downloadButton.IsLoaded); - AddAssert("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + AddAssert("state is unknown", () => downloadButton.State.Value == DownloadState.Unknown); AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } - private ScoreInfo getScoreInfo(bool replayAvailable, bool hasOnlineId = true) + private ScoreInfo getScoreInfo(bool replayAvailable, bool hasOnlineId = true) => new ScoreInfo { - return new APIScore + OnlineID = hasOnlineId ? online_score_id : 0, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(), + Hash = replayAvailable ? "online" : string.Empty, + User = new APIUser { - OnlineID = hasOnlineId ? online_score_id : 0, - RulesetID = 0, - Beatmap = CreateAPIBeatmapSet(new OsuRuleset().RulesetInfo).Beatmaps.First(), - HasReplay = replayAvailable, - User = new APIUser - { - Id = 39828, - Username = @"WubWoofWolf", - } - }.CreateScoreInfo(rulesets, beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First()); - } + Id = 39828, + Username = @"WubWoofWolf", + } + }; private class TestReplayDownloadButton : ReplayDownloadButton { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index 54b2e66f2f..b3401c916b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -3,10 +3,10 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -40,8 +40,7 @@ namespace osu.Game.Tests.Visual.Gameplay private TestReplayRecorder recorder; - [Cached] - private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); + private GameplayState gameplayState; [SetUpSteps] public void SetUpSteps() @@ -52,81 +51,15 @@ namespace osu.Game.Tests.Visual.Gameplay { replay = new Replay(); - Add(new GridContainer + gameplayState = TestGameplayState.Create(new OsuRuleset()); + gameplayState.Score.Replay = replay; + + Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - Recorder = recorder = new TestReplayRecorder(new Score - { - Replay = replay, - ScoreInfo = - { - BeatmapInfo = gameplayState.Beatmap.BeatmapInfo, - Ruleset = new OsuRuleset().RulesetInfo, - } - }) - { - ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.Brown, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Recording", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - ReplayInputHandler = new TestFramedReplayInputHandler(replay) - { - GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.DarkBlue, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Playback", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - } - } - }); + CachedDependencies = new (Type, object)[] { (typeof(GameplayState), gameplayState) }, + Child = createContent(), + }; }); } @@ -203,6 +136,74 @@ namespace osu.Game.Tests.Visual.Gameplay recorder = null; } + private Drawable createContent() => new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = recorder = new TestReplayRecorder(gameplayState.Score) + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Recording", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + ReplayInputHandler = new TestFramedReplayInputHandler(replay) + { + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Playback", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + } + } + }; + public class TestFramedReplayInputHandler : FramedReplayInputHandler { public TestFramedReplayInputHandler(Replay replay) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index 8a4818d2f8..6d036f8e9b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -11,8 +11,10 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; @@ -167,14 +169,39 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); } - private void addHitObject(double time) + [Test] + public void TestVeryFlowScroll() + { + const double long_time_range = 100000; + var manualClock = new ManualClock(); + + AddStep("set manual clock", () => + { + manualClock.CurrentTime = 0; + scrollContainers.ForEach(c => c.Clock = new FramedClock(manualClock)); + + setScrollAlgorithm(ScrollVisualisationMethod.Constant); + scrollContainers.ForEach(c => c.TimeRange = long_time_range); + }); + + AddStep("add hit objects", () => + { + addHitObject(long_time_range); + addHitObject(long_time_range + 100, 250); + }); + + AddAssert("hit objects are alive", () => playfields.All(p => p.HitObjectContainer.AliveObjects.Count() == 2)); + } + + private void addHitObject(double time, float size = 75) { playfields.ForEach(p => { - var hitObject = new TestDrawableHitObject(time); - setAnchor(hitObject, p); + var hitObject = new TestHitObject(size) { StartTime = time }; + var drawable = new TestDrawableHitObject(hitObject); - p.Add(hitObject); + setAnchor(drawable, p); + p.Add(drawable); }); } @@ -187,7 +214,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void addControlPoints(IList controlPoints, double sequenceStartTime) { - controlPoints.ForEach(point => point.StartTime += sequenceStartTime); + controlPoints.ForEach(point => point.Time += sequenceStartTime); scrollContainers.ForEach(container => { @@ -197,7 +224,7 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (var playfield in playfields) { foreach (var controlPoint in controlPoints) - playfield.Add(createDrawablePoint(playfield, controlPoint.StartTime)); + playfield.Add(createDrawablePoint(playfield, controlPoint.Time)); } } @@ -248,6 +275,8 @@ namespace osu.Game.Tests.Visual.Gameplay } }; } + + protected override ScrollingHitObjectContainer CreateScrollingHitObjectContainer() => new TestScrollingHitObjectContainer(); } private class TestDrawableControlPoint : DrawableHitObject @@ -281,22 +310,41 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class TestDrawableHitObject : DrawableHitObject + private class TestHitObject : HitObject { - public TestDrawableHitObject(double time) - : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) - { - Origin = Anchor.Custom; - OriginPosition = new Vector2(75 / 4.0f); + public readonly float Size; - AutoSizeAxes = Axes.Both; + public TestHitObject(float size) + { + Size = size; + } + } + + private class TestDrawableHitObject : DrawableHitObject + { + public TestDrawableHitObject(TestHitObject hitObject) + : base(hitObject) + { + Origin = Anchor.Centre; + Size = new Vector2(hitObject.Size); AddInternal(new Box { - Size = new Vector2(75), + RelativeSizeAxes = Axes.Both, Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) }); } } + + private class TestScrollingHitObjectContainer : ScrollingHitObjectContainer + { + protected override RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) + { + if (entry.HitObject is TestHitObject testObject) + return new RectangleF().Inflate(testObject.Size / 2); + + return base.GetConservativeBoundingBox(entry); + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index f319290441..578718b7c9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.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.Allocation; @@ -14,6 +12,7 @@ using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; using osu.Game.Skinning.Editor; using osuTK.Input; @@ -21,7 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSkinEditor : PlayerTestScene { - private SkinEditor skinEditor; + private SkinEditor? skinEditor; protected override bool Autoplay => true; @@ -33,35 +32,41 @@ namespace osu.Game.Tests.Visual.Gameplay { base.SetUpSteps(); + AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); + AddStep("reload skin editor", () => { skinEditor?.Expire(); Player.ScaleTo(0.4f); LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); }); - AddUntilStep("wait for loaded", () => skinEditor.IsLoaded); + AddUntilStep("wait for loaded", () => skinEditor!.IsLoaded); } [Test] public void TestToggleEditor() { - AddToggleStep("toggle editor visibility", _ => skinEditor.ToggleVisibility()); + AddToggleStep("toggle editor visibility", _ => skinEditor!.ToggleVisibility()); } [Test] public void TestEditComponent() { - BarHitErrorMeter hitErrorMeter = null; + BarHitErrorMeter hitErrorMeter = null!; AddStep("select bar hit error blueprint", () => { var blueprint = skinEditor.ChildrenOfType().First(b => b.Item is BarHitErrorMeter); hitErrorMeter = (BarHitErrorMeter)blueprint.Item; - skinEditor.SelectedComponents.Clear(); + skinEditor!.SelectedComponents.Clear(); skinEditor.SelectedComponents.Add(blueprint.Item); }); + AddStep("move by keyboard", () => InputManager.Key(Key.Right)); + + AddAssert("hitErrorMeter moved", () => hitErrorMeter.X != 0); + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); AddStep("hover first slider", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 3eb92b3e97..6c02ddab14 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -29,8 +29,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); - [Cached] - private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + [Cached(typeof(IGameplayClock))] + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); [SetUpSteps] public void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs index eacab6d34f..ef56f456ea 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs @@ -10,6 +10,7 @@ using osu.Framework.Testing; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index 16593effd6..d4fba76c37 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -13,7 +13,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Game.Audio; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index ee2827122d..485c76ac5c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -36,8 +36,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); - [Cached] - private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + [Cached(typeof(IGameplayClock))] + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private IEnumerable hudOverlays => CreatedDrawables.OfType(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index 36b07043dc..0ba112ec40 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -13,7 +13,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index e1fc65404d..6b02449aa3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; using osuTK; @@ -22,25 +23,23 @@ namespace osu.Game.Tests.Visual.Gameplay private double increment; private GameplayClockContainer gameplayClockContainer; - private GameplayClock gameplayClock; + private IFrameBasedClock gameplayClock; private const double skip_time = 6000; - [SetUp] - public void SetUp() => Schedule(() => + private void createTest(double skipTime = skip_time) => AddStep("create test", () => { requestCount = 0; increment = skip_time; var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); - working.LoadTrack(); Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - skip = new TestSkipOverlay(skip_time) + skip = new TestSkipOverlay(skipTime) { RequestSkip = () => { @@ -52,12 +51,28 @@ namespace osu.Game.Tests.Visual.Gameplay }; gameplayClockContainer.Start(); - gameplayClock = gameplayClockContainer.GameplayClock; + gameplayClock = gameplayClockContainer; }); + [Test] + public void TestSkipTimeZero() + { + createTest(0); + AddUntilStep("wait for skip overlay expired", () => !skip.IsAlive); + } + + [Test] + public void TestSkipTimeEqualToSkip() + { + createTest(MasterGameplayClockContainer.MINIMUM_SKIP_TIME); + AddUntilStep("wait for skip overlay expired", () => !skip.IsAlive); + } + [Test] public void TestFadeOnIdle() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(Vector2.Zero)); AddUntilStep("fully visible", () => skip.FadingContent.Alpha == 1); AddUntilStep("wait for fade", () => skip.FadingContent.Alpha < 1); @@ -70,6 +85,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestClickableAfterFade() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddUntilStep("wait for fade", () => skip.FadingContent.Alpha == 0); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -79,6 +96,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestClickOnlyActuatesOnce() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("click", () => { @@ -94,6 +113,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestClickOnlyActuatesMultipleTimes() { + createTest(); + AddStep("set increment lower", () => increment = 3000); AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -106,6 +127,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestDoesntFadeOnMouseDown() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("button down", () => InputManager.PressButton(MouseButton.Left)); AddUntilStep("wait for overlay disappear", () => !skip.OverlayContent.IsPresent); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs new file mode 100644 index 0000000000..60ed0012ae --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs @@ -0,0 +1,101 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSoloGameplayLeaderboard : OsuTestScene + { + [Cached] + private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + + private readonly BindableList scores = new BindableList(); + + private readonly Bindable configVisibility = new Bindable(); + + private SoloGameplayLeaderboard leaderboard = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear scores", () => scores.Clear()); + + AddStep("create component", () => + { + var trackingUser = new APIUser + { + Username = "local user", + Id = 2, + }; + + Child = leaderboard = new SoloGameplayLeaderboard(trackingUser) + { + Scores = { BindTarget = scores }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AlwaysVisible = { Value = false }, + Expanded = { Value = true }, + }; + }); + + AddStep("add scores", () => scores.AddRange(createSampleScores())); + } + + [Test] + public void TestLocalUser() + { + AddSliderStep("score", 0, 1000000, 500000, v => scoreProcessor.TotalScore.Value = v); + AddSliderStep("accuracy", 0f, 1f, 0.5f, v => scoreProcessor.Accuracy.Value = v); + AddSliderStep("combo", 0, 10000, 0, v => scoreProcessor.HighestCombo.Value = v); + AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); + } + + [Test] + public void TestVisibility() + { + AddStep("set config visible true", () => configVisibility.Value = true); + AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1); + + AddStep("set config visible false", () => configVisibility.Value = false); + AddUntilStep("leaderboard not visible", () => leaderboard.Alpha == 0); + + AddStep("set always visible", () => leaderboard.AlwaysVisible.Value = true); + AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1); + + AddStep("set config visible true", () => configVisibility.Value = true); + AddAssert("leaderboard still visible", () => leaderboard.Alpha == 1); + } + + private static List createSampleScores() + { + return new[] + { + new ScoreInfo { User = new APIUser { Username = @"peppy" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"smoogipoo" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"spaceman_atlas" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"frenzibyte" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"Susko3" }, TotalScore = RNG.Next(500000, 1000000) }, + }.Concat(Enumerable.Range(0, 50).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList(); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs index 07efb25b46..6127aa304c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs @@ -1,161 +1,77 @@ // 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 NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; -using osu.Framework.Utils; -using osu.Framework.Timing; -using osu.Game.Graphics; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneSongProgress : OsuTestScene + public class TestSceneSongProgress : SkinnableHUDComponentTestScene { - private SongProgress progress; - private TestSongProgressGraph graph; - private readonly Container progressContainer; + private GameplayClockContainer gameplayClockContainer = null!; - private readonly StopwatchClock clock; - private readonly FramedClock framedClock; + private const double skip_target_time = -2000; - [Cached] - private readonly GameplayClock gameplayClock; - - public TestSceneSongProgress() + [BackgroundDependencyLoader] + private void load() { - clock = new StopwatchClock(); - gameplayClock = new GameplayClock(framedClock = new FramedClock(clock)); + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - Add(progressContainer = new Container - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Height = 100, - Y = -100, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(1), - } - }); + Add(gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, skip_target_time)); + + Dependencies.CacheAs(gameplayClockContainer); } [SetUpSteps] public void SetupSteps() { - AddStep("add new song progress", () => - { - if (progress != null) - { - progress.Expire(); - progress = null; - } - - progressContainer.Add(progress = new SongProgress - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - }); - }); - - AddStep("add new big graph", () => - { - if (graph != null) - { - graph.Expire(); - graph = null; - } - - Add(graph = new TestSongProgressGraph - { - RelativeSizeAxes = Axes.X, - Height = 200, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - }); - }); - - AddStep("reset clock", clock.Reset); - } - - [Test] - public void TestGraphRecreation() - { - AddAssert("ensure not created", () => graph.CreationCount == 0); - AddStep("display values", displayRandomValues); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - AddRepeatStep("new values", displayRandomValues, 5); - AddWaitStep("wait some", 5); - AddAssert("ensure recreation debounced", () => graph.CreationCount == 2); + AddStep("reset clock", () => gameplayClockContainer.Reset()); + AddStep("set hit objects", setHitObjects); } [Test] public void TestDisplay() { - AddStep("display max values", displayMaxValues); - AddUntilStep("wait for graph", () => graph.CreationCount == 1); - AddStep("start", clock.Start); - AddStep("allow seeking", () => progress.AllowSeeking.Value = true); - AddStep("hide graph", () => progress.ShowGraph.Value = false); - AddStep("disallow seeking", () => progress.AllowSeeking.Value = false); - AddStep("allow seeking", () => progress.AllowSeeking.Value = true); - AddStep("show graph", () => progress.ShowGraph.Value = true); - AddStep("stop", clock.Stop); + AddStep("seek to intro", () => gameplayClockContainer.Seek(skip_target_time)); + AddStep("start", gameplayClockContainer.Start); + AddStep("stop", gameplayClockContainer.Stop); } - private void displayRandomValues() + [Test] + public void TestToggleSeeking() { - var objects = new List(); - for (double i = 0; i < 5000; i += RNG.NextDouble() * 10 + i / 1000) - objects.Add(new HitObject { StartTime = i }); + void applyToDefaultProgress(Action action) => + this.ChildrenOfType().ForEach(action); - replaceObjects(objects); + AddStep("allow seeking", () => applyToDefaultProgress(s => s.AllowSeeking.Value = true)); + AddStep("hide graph", () => applyToDefaultProgress(s => s.ShowGraph.Value = false)); + AddStep("disallow seeking", () => applyToDefaultProgress(s => s.AllowSeeking.Value = false)); + AddStep("allow seeking", () => applyToDefaultProgress(s => s.AllowSeeking.Value = true)); + AddStep("show graph", () => applyToDefaultProgress(s => s.ShowGraph.Value = true)); } - private void displayMaxValues() + private void setHitObjects() { var objects = new List(); for (double i = 0; i < 5000; i++) objects.Add(new HitObject { StartTime = i }); - replaceObjects(objects); + this.ChildrenOfType().ForEach(progress => progress.Objects = objects); } - private void replaceObjects(List objects) - { - progress.Objects = objects; - graph.Objects = objects; + protected override Drawable CreateDefaultImplementation() => new DefaultSongProgress(); - progress.RequestSeek = pos => clock.Seek(pos); - } - - protected override void Update() - { - base.Update(); - framedClock.ProcessFrame(); - } - - private class TestSongProgressGraph : SongProgressGraph - { - public int CreationCount { get; private set; } - - protected override void RecreateGraph() - { - base.RecreateGraph(); - CreationCount++; - } - } + protected override Drawable CreateLegacyImplementation() => new LegacySongProgress(); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgressGraph.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgressGraph.cs new file mode 100644 index 0000000000..2fa3c0c7ec --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgressGraph.cs @@ -0,0 +1,73 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneSongProgressGraph : OsuTestScene + { + private TestSongProgressGraph graph; + + [SetUpSteps] + public void SetupSteps() + { + AddStep("add new big graph", () => + { + if (graph != null) + { + graph.Expire(); + graph = null; + } + + Add(graph = new TestSongProgressGraph + { + RelativeSizeAxes = Axes.X, + Height = 200, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }); + }); + } + + [Test] + public void TestGraphRecreation() + { + AddAssert("ensure not created", () => graph.CreationCount == 0); + AddStep("display values", displayRandomValues); + AddUntilStep("wait for creation count", () => graph.CreationCount == 1); + AddRepeatStep("new values", displayRandomValues, 5); + AddWaitStep("wait some", 5); + AddAssert("ensure recreation debounced", () => graph.CreationCount == 2); + } + + private void displayRandomValues() + { + var objects = new List(); + for (double i = 0; i < 5000; i += RNG.NextDouble() * 10 + i / 1000) + objects.Add(new HitObject { StartTime = i }); + + graph.Objects = objects; + } + + private class TestSongProgressGraph : SongProgressGraph + { + public int CreationCount { get; private set; } + + protected override void RecreateGraph() + { + base.RecreateGraph(); + CreationCount++; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index a42e86933f..929af7f84d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -85,7 +85,7 @@ namespace osu.Game.Tests.Visual.Gameplay sendFrames(startTime: gameplay_start); - AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start)); } /// @@ -119,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay waitForPlayer(); AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing); - AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start)); } [Test] @@ -147,7 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame); checkPaused(true); - AddAssert("time advanced", () => currentFrameStableTime > pausedTime); + AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime)); } [Test] @@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Gameplay sendFrames(300); - AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime > 30000); + AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000)); } [Test] @@ -363,7 +363,7 @@ namespace osu.Game.Tests.Visual.Gameplay private Player player => Stack.CurrentScreen as Player; private double currentFrameStableTime - => player.ChildrenOfType().First().FrameStableClock.CurrentTime; + => player.ChildrenOfType().First().CurrentTime; private void waitForPlayer() => AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs index c55e98c1a8..083be3539d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; @@ -43,11 +44,26 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("spectator client sent correct ruleset", () => spectatorClient.WatchedUserStates[dummy_user_id].RulesetID == Ruleset.Value.OnlineID); } + [Test] + public void TestRestart() + { + AddAssert("spectator client sees playing state", () => spectatorClient.WatchedUserStates[dummy_user_id].State == SpectatedUserState.Playing); + + AddStep("exit player", () => Player.Exit()); + AddStep("reload player", LoadPlayer); + AddUntilStep("wait for player load", () => Player.IsLoaded && Player.Alpha == 1); + + AddAssert("spectator client sees playing state", () => spectatorClient.WatchedUserStates[dummy_user_id].State == SpectatedUserState.Playing); + + AddWaitStep("wait", 5); + AddUntilStep("spectator client still sees playing state", () => spectatorClient.WatchedUserStates[dummy_user_id].State == SpectatedUserState.Playing); + } + public override void TearDownSteps() { base.TearDownSteps(); AddStep("stop watching user", () => spectatorClient.StopWatchingUser(dummy_user_id)); - AddStep("remove test spectator client", () => Remove(spectatorClient)); + AddStep("remove test spectator client", () => Remove(spectatorClient, false)); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 5fad661e9b..9c41c70a0e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -27,7 +27,6 @@ using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Screens.Play; using osu.Game.Tests.Gameplay; using osu.Game.Tests.Mods; using osu.Game.Tests.Visual.Spectator; @@ -41,16 +40,12 @@ namespace osu.Game.Tests.Visual.Gameplay private TestRulesetInputManager playbackManager; private TestRulesetInputManager recordingManager; - private Replay replay; - + private Score recordingScore; + private Replay playbackReplay; private TestSpectatorClient spectatorClient; - private ManualClock manualClock; - private TestReplayRecorder recorder; - private OsuSpriteText latencyDisplay; - private TestFramedReplayInputHandler replayHandler; [SetUpSteps] @@ -58,7 +53,16 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("Setup containers", () => { - replay = new Replay(); + recordingScore = new Score + { + ScoreInfo = + { + BeatmapInfo = new BeatmapInfo(), + Ruleset = new OsuRuleset().RulesetInfo, + } + }; + + playbackReplay = new Replay(); manualClock = new ManualClock(); Child = new DependencyProvidingContainer @@ -67,7 +71,6 @@ namespace osu.Game.Tests.Visual.Gameplay CachedDependencies = new[] { (typeof(SpectatorClient), (object)(spectatorClient = new TestSpectatorClient())), - (typeof(GameplayState), TestGameplayState.Create(new OsuRuleset())) }, Children = new Drawable[] { @@ -81,7 +84,7 @@ namespace osu.Game.Tests.Visual.Gameplay { recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - Recorder = recorder = new TestReplayRecorder + Recorder = recorder = new TestReplayRecorder(recordingScore) { ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), }, @@ -112,7 +115,7 @@ namespace osu.Game.Tests.Visual.Gameplay playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { Clock = new FramedClock(manualClock), - ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay) + ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(playbackReplay) { GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), }, @@ -144,6 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay } }; + spectatorClient.BeginPlaying(TestGameplayState.Create(new OsuRuleset()), recordingScore); spectatorClient.OnNewFrames += onNewFrames; }); } @@ -151,15 +155,15 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestBasic() { - AddUntilStep("received frames", () => replay.Frames.Count > 50); + AddUntilStep("received frames", () => playbackReplay.Frames.Count > 50); AddStep("stop sending frames", () => recorder.Expire()); - AddUntilStep("wait for all frames received", () => replay.Frames.Count == recorder.SentFrames.Count); + AddUntilStep("wait for all frames received", () => playbackReplay.Frames.Count == recorder.SentFrames.Count); } [Test] public void TestWithSendFailure() { - AddUntilStep("received frames", () => replay.Frames.Count > 50); + AddUntilStep("received frames", () => playbackReplay.Frames.Count > 50); int framesReceivedSoFar = 0; int frameSendAttemptsSoFar = 0; @@ -172,21 +176,21 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for next send attempt", () => { - framesReceivedSoFar = replay.Frames.Count; + framesReceivedSoFar = playbackReplay.Frames.Count; return spectatorClient.FrameSendAttempts > frameSendAttemptsSoFar + 1; }); AddUntilStep("wait for more send attempts", () => spectatorClient.FrameSendAttempts > frameSendAttemptsSoFar + 10); - AddAssert("frames did not increase", () => framesReceivedSoFar == replay.Frames.Count); + AddAssert("frames did not increase", () => framesReceivedSoFar == playbackReplay.Frames.Count); AddStep("stop failing sends", () => spectatorClient.ShouldFailSendingFrames = false); - AddUntilStep("wait for next frames", () => framesReceivedSoFar < replay.Frames.Count); + AddUntilStep("wait for next frames", () => framesReceivedSoFar < playbackReplay.Frames.Count); AddStep("stop sending frames", () => recorder.Expire()); - AddUntilStep("wait for all frames received", () => replay.Frames.Count == recorder.SentFrames.Count); - AddAssert("ensure frames were received in the correct sequence", () => replay.Frames.Select(f => f.Time).SequenceEqual(recorder.SentFrames.Select(f => f.Time))); + AddUntilStep("wait for all frames received", () => playbackReplay.Frames.Count == recorder.SentFrames.Count); + AddAssert("ensure frames were received in the correct sequence", () => playbackReplay.Frames.Select(f => f.Time).SequenceEqual(recorder.SentFrames.Select(f => f.Time))); } private void onNewFrames(int userId, FrameDataBundle frames) @@ -195,10 +199,10 @@ namespace osu.Game.Tests.Visual.Gameplay { var frame = new TestReplayFrame(); frame.FromLegacy(legacyFrame, null); - replay.Frames.Add(frame); + playbackReplay.Frames.Add(frame); } - Logger.Log($"Received {frames.Frames.Count} new frames (total {replay.Frames.Count} of {recorder.SentFrames.Count})"); + Logger.Log($"Received {frames.Frames.Count} new frames (total {playbackReplay.Frames.Count} of {recorder.SentFrames.Count})"); } private double latency = SpectatorClient.TIME_BETWEEN_SENDS; @@ -219,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay if (!replayHandler.HasFrames) return; - var lastFrame = replay.Frames.LastOrDefault(); + var lastFrame = playbackReplay.Frames.LastOrDefault(); // this isn't perfect as we basically can't be aware of the rate-of-send here (the streamer is not sending data when not being moved). // in gameplay playback, the case where NextFrame is null would pause gameplay and handle this correctly; it's strictly a test limitation / best effort implementation. @@ -360,15 +364,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public List SentFrames = new List(); - public TestReplayRecorder() - : base(new Score - { - ScoreInfo = - { - BeatmapInfo = new BeatmapInfo(), - Ruleset = new OsuRuleset().RulesetInfo, - } - }) + public TestReplayRecorder(Score score) + : base(score) { } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index 30c2790fb4..eaf22ba9cc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -24,8 +22,9 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneStoryboard : OsuTestScene { - private Container storyboardContainer; - private DrawableStoryboard storyboard; + private Container storyboardContainer = null!; + + private DrawableStoryboard? storyboard; [Test] public void TestStoryboard() @@ -40,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestStoryboardMissingVideo() { - AddStep("Load storyboard with missing video", loadStoryboardNoVideo); + AddStep("Load storyboard with missing video", () => loadStoryboard("storyboard_no_video.osu")); } [BackgroundDependencyLoader] @@ -77,53 +76,44 @@ namespace osu.Game.Tests.Visual.Gameplay Beatmap.BindValueChanged(beatmapChanged, true); } - private void beatmapChanged(ValueChangedEvent e) => loadStoryboard(e.NewValue); + private void beatmapChanged(ValueChangedEvent e) => loadStoryboard(e.NewValue.Storyboard); private void restart() { var track = Beatmap.Value.Track; track.Reset(); - loadStoryboard(Beatmap.Value); + loadStoryboard(Beatmap.Value.Storyboard); track.Start(); } - private void loadStoryboard(IWorkingBeatmap working) + private void loadStoryboard(Storyboard toLoad) { if (storyboard != null) - storyboardContainer.Remove(storyboard); + storyboardContainer.Remove(storyboard, true); var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; storyboardContainer.Clock = decoupledClock; - storyboard = working.Storyboard.CreateDrawable(SelectedMods.Value); + storyboard = toLoad.CreateDrawable(SelectedMods.Value); storyboard.Passing = false; - storyboardContainer.Add(storyboard); - decoupledClock.ChangeSource(working.Track); - } - - private void loadStoryboardNoVideo() - { - if (storyboard != null) - storyboardContainer.Remove(storyboard); - - var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; - storyboardContainer.Clock = decoupledClock; - - Storyboard sb; - - using (var str = TestResources.OpenResource("storyboard_no_video.osu")) - using (var bfr = new LineBufferedReader(str)) - { - var decoder = new LegacyStoryboardDecoder(); - sb = decoder.Decode(bfr); - } - - storyboard = sb.CreateDrawable(SelectedMods.Value); - storyboardContainer.Add(storyboard); decoupledClock.ChangeSource(Beatmap.Value.Track); } + + private void loadStoryboard(string filename) + { + Storyboard loaded; + + using (var str = TestResources.OpenResource(filename)) + using (var bfr = new LineBufferedReader(str)) + { + var decoder = new LegacyStoryboardDecoder(); + loaded = decoder.Decode(bfr); + } + + loadStoryboard(loaded); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index e2b2ad85a3..a26a7e97be 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -32,6 +32,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected new OutroPlayer Player => (OutroPlayer)base.Player; + private double currentBeatmapDuration; private double currentStoryboardDuration; private bool showResults = true; @@ -45,7 +46,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true)); AddStep("set dim level to 0", () => LocalConfig.SetValue(OsuSetting.DimLevel, 0)); AddStep("reset fail conditions", () => currentFailConditions = (_, _) => false); - AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000); + AddStep("set beatmap duration to 0s", () => currentBeatmapDuration = 0); + AddStep("set storyboard duration to 8s", () => currentStoryboardDuration = 8000); AddStep("set ShowResults = true", () => showResults = true); } @@ -64,7 +66,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestStoryboardNoSkipOutro() { CreateTest(); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddUntilStep("wait for score shown", () => Player.IsScoreShown); } @@ -100,7 +102,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); } @@ -111,7 +113,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("set ShowResults = false", () => showResults = false); }); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddWaitStep("wait", 10); AddAssert("no score shown", () => !Player.IsScoreShown); } @@ -120,7 +122,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestStoryboardEndsBeforeCompletion() { CreateTest(() => AddStep("set storyboard duration to .1s", () => currentStoryboardDuration = 100)); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddUntilStep("wait for score shown", () => Player.IsScoreShown); } @@ -138,7 +140,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("skip overlay content not visible", () => fadeContainer().State == Visibility.Hidden); AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); } [Test] @@ -151,6 +153,24 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("player exited", () => Stack.CurrentScreen == null); } + [Test] + public void TestPerformExitAfterOutro() + { + CreateTest(() => + { + AddStep("set beatmap duration to 4s", () => currentBeatmapDuration = 4000); + AddStep("set storyboard duration to 1s", () => currentStoryboardDuration = 1000); + }); + + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); + AddStep("exit via pause", () => Player.ExitViaPause()); + AddAssert("player paused", () => !Player.IsResuming); + + AddStep("resume player", () => Player.Resume()); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + protected override bool AllowFail => true; protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); @@ -160,7 +180,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap(); - beatmap.HitObjects.Add(new HitCircle()); + beatmap.HitObjects.Add(new HitCircle { StartTime = currentBeatmapDuration }); return beatmap; } @@ -189,7 +209,7 @@ namespace osu.Game.Tests.Visual.Gameplay private event Func failConditions; public OutroPlayer(Func failConditions, bool showResults = true) - : base(false, showResults) + : base(showResults: showResults) { this.failConditions = failConditions; } diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index 66e3612113..e0f0df0554 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -52,6 +52,7 @@ namespace osu.Game.Tests.Visual.Menus }, notifications = new NotificationOverlay { + Depth = float.MinValue, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, } @@ -82,7 +83,14 @@ namespace osu.Game.Tests.Visual.Menus [Test] public virtual void TestPlayIntroWithFailingAudioDevice() { - AddStep("hide notifications", () => notifications.Hide()); + AddStep("reset notifications", () => + { + notifications.Show(); + notifications.Hide(); + }); + + AddUntilStep("wait for no notifications", () => notifications.UnreadCount.Value, () => Is.EqualTo(0)); + AddStep("restart sequence", () => { logo.FinishTransforms(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index 90e218675c..4473f315b9 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -62,8 +62,8 @@ namespace osu.Game.Tests.Visual.Menus [SetUp] public void SetUp() => Schedule(() => { - Remove(nowPlayingOverlay); - Remove(volumeOverlay); + Remove(nowPlayingOverlay, false); + Remove(volumeOverlay, false); Children = new Drawable[] { diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs new file mode 100644 index 0000000000..2901501b30 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -0,0 +1,87 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API; +using osu.Game.Overlays.Toolbar; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public class TestSceneToolbarUserButton : OsuManualInputManagerTestScene + { + public TestSceneToolbarUserButton() + { + Container mainContainer; + + Children = new Drawable[] + { + mainContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = Toolbar.HEIGHT, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkRed, + RelativeSizeAxes = Axes.Y, + Width = 2, + }, + new ToolbarUserButton(), + new Box + { + Colour = Color4.DarkRed, + RelativeSizeAxes = Axes.Y, + Width = 2, + }, + } + }, + } + }, + }; + + AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale)); + } + + [Test] + public void TestLoginLogout() + { + AddStep("Log out", () => ((DummyAPIAccess)API).Logout()); + AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + } + + [Test] + public void TestStates() + { + AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + + foreach (var state in Enum.GetValues()) + { + AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs index 91fa09b414..23c1eda7f7 100644 --- a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs +++ b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.Mods protected override TestPlayer CreateModPlayer(Ruleset ruleset) { var player = base.CreateModPlayer(ruleset); - player.RestartRequested = () => restartRequested = true; + player.RestartRequested = _ => restartRequested = true; return player; } diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 2b461cf6f6..ca4d926866 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -35,7 +35,6 @@ namespace osu.Game.Tests.Visual.Multiplayer protected IScreen CurrentSubScreen => multiplayerComponents.MultiplayerScreen.CurrentSubScreen; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private TestMultiplayerComponents multiplayerComponents; @@ -45,8 +44,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs index 0a59e0e858..b26481387d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs @@ -19,29 +19,33 @@ namespace osu.Game.Tests.Visual.Multiplayer { private DrawableRoomParticipantsList list; - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room - { - Name = { Value = "test room" }, - Host = - { - Value = new APIUser - { - Id = 2, - Username = "peppy", - } - } - }; + base.SetUpSteps(); - Child = list = new DrawableRoomParticipantsList + AddStep("create list", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - NumberOfCircles = 4 - }; - }); + SelectedRoom.Value = new Room + { + Name = { Value = "test room" }, + Host = + { + Value = new APIUser + { + Id = 2, + Username = "peppy", + } + } + }; + + Child = list = new DrawableRoomParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + NumberOfCircles = 4 + }; + }); + } [Test] public void TestCircleCountNearLimit() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 757dfff2b7..73d1222156 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -18,6 +18,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Models; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -37,13 +38,12 @@ namespace osu.Game.Tests.Visual.Multiplayer private TestPlaylist playlist; private BeatmapManager manager; - private RulesetStore rulesets; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -195,12 +195,15 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestDownloadButtonHiddenWhenBeatmapExists() { - var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; Live imported = null; - Debug.Assert(beatmap.BeatmapSet != null); + AddStep("import beatmap", () => + { + var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; - AddStep("import beatmap", () => imported = manager.Import(beatmap.BeatmapSet)); + Debug.Assert(beatmap.BeatmapSet != null); + imported = manager.Import(beatmap.BeatmapSet); + }); createPlaylistWithBeatmaps(() => imported.PerformRead(s => s.Beatmaps.Detach())); @@ -245,40 +248,35 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestExpiredItems() { - AddStep("create playlist", () => + createPlaylist(p => { - Child = playlist = new TestPlaylist + p.Items.Clear(); + p.Items.AddRange(new[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 300), - Items = + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + ID = 0, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Expired = true, + RequiredMods = new[] { - ID = 0, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - Expired = true, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } - }, - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + new APIMod(new OsuModHardRock()), + new APIMod(new OsuModDoubleTime()), + new APIMod(new OsuModAutoplay()) + } + }, + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + ID = 1, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + RequiredMods = new[] { - ID = 1, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } + new APIMod(new OsuModHardRock()), + new APIMod(new OsuModDoubleTime()), + new APIMod(new OsuModAutoplay()) } } - }; + }); }); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); @@ -321,19 +319,44 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); + private void createPlaylistWithBeatmaps(Func> beatmaps) => createPlaylist(p => + { + int index = 0; + + p.Items.Clear(); + + foreach (var b in beatmaps()) + { + p.Items.Add(new PlaylistItem(b) + { + ID = index++, + OwnerID = 2, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + RequiredMods = new[] + { + new APIMod(new OsuModHardRock()), + new APIMod(new OsuModDoubleTime()), + new APIMod(new OsuModAutoplay()) + } + }); + } + }); + private void createPlaylist(Action setupPlaylist = null) { AddStep("create playlist", () => { - Child = playlist = new TestPlaylist + Child = new OsuContextMenuContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 300) + RelativeSizeAxes = Axes.Both, + Child = playlist = new TestPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + } }; - setupPlaylist?.Invoke(playlist); - for (int i = 0; i < 20; i++) { playlist.Items.Add(new PlaylistItem(i % 2 == 1 @@ -360,39 +383,8 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); } - }); - AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); - } - - private void createPlaylistWithBeatmaps(Func> beatmaps) - { - AddStep("create playlist", () => - { - Child = playlist = new TestPlaylist - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 300) - }; - - int index = 0; - - foreach (var b in beatmaps()) - { - playlist.Items.Add(new PlaylistItem(b) - { - ID = index++, - OwnerID = 2, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } - }); - } + setupPlaylist?.Invoke(playlist); }); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 82e7bf8969..3d6d4f0a90 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -25,23 +25,27 @@ namespace osu.Game.Tests.Visual.Multiplayer private RoomsContainer container; - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - Child = new PopoverContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, + base.SetUpSteps(); - Child = container = new RoomsContainer + AddStep("create container", () => + { + Child = new PopoverContainer { - SelectedRoom = { BindTarget = SelectedRoom } - } - }; - }); + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + + Child = container = new RoomsContainer + { + SelectedRoom = { BindTarget = SelectedRoom } + } + }; + }); + } [Test] public void TestBasicListChanges() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 8cdcdfdfdf..b113352117 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -3,7 +3,6 @@ #nullable disable -using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -18,19 +17,23 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room(); + base.SetUpSteps(); - Child = new MatchBeatmapDetailArea + AddStep("create area", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500), - CreateNewItem = createNewItem - }; - }); + SelectedRoom.Value = new Room(); + + Child = new MatchBeatmapDetailArea + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500), + CreateNewItem = createNewItem + }; + }); + } private void createNewItem() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 506d7541a7..d2468ae005 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -4,8 +4,6 @@ #nullable disable using System.Collections.Generic; -using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -19,59 +17,62 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchLeaderboard : OnlinePlayTestScene { - [BackgroundDependencyLoader] - private void load() + public override void SetUpSteps() { - ((DummyAPIAccess)API).HandleRequest = r => + base.SetUpSteps(); + + AddStep("setup API", () => { - switch (r) + ((DummyAPIAccess)API).HandleRequest = r => { - case GetRoomLeaderboardRequest leaderboardRequest: - leaderboardRequest.TriggerSuccess(new APILeaderboard - { - Leaderboard = new List + switch (r) + { + case GetRoomLeaderboardRequest leaderboardRequest: + leaderboardRequest.TriggerSuccess(new APILeaderboard { - new APIUserScoreAggregate + Leaderboard = new List { - UserID = 2, - User = new APIUser { Id = 2, Username = "peppy" }, - TotalScore = 995533, - RoomID = 3, - CompletedBeatmaps = 1, - TotalAttempts = 6, - Accuracy = 0.9851 - }, - new APIUserScoreAggregate - { - UserID = 1040328, - User = new APIUser { Id = 1040328, Username = "smoogipoo" }, - TotalScore = 981100, - RoomID = 3, - CompletedBeatmaps = 1, - TotalAttempts = 9, - Accuracy = 0.937 + new APIUserScoreAggregate + { + UserID = 2, + User = new APIUser { Id = 2, Username = "peppy" }, + TotalScore = 995533, + RoomID = 3, + CompletedBeatmaps = 1, + TotalAttempts = 6, + Accuracy = 0.9851 + }, + new APIUserScoreAggregate + { + UserID = 1040328, + User = new APIUser { Id = 1040328, Username = "smoogipoo" }, + TotalScore = 981100, + RoomID = 3, + CompletedBeatmaps = 1, + TotalAttempts = 9, + Accuracy = 0.937 + } } - } - }); - return true; - } + }); + return true; + } - return false; - }; - } + return false; + }; + }); - [SetUp] - public new void Setup() => Schedule(() => - { - SelectedRoom.Value = new Room { RoomID = { Value = 3 } }; - - Child = new MatchLeaderboard + AddStep("create leaderboard", () => { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = MatchLeaderboardScope.Overall, - }; - }); + SelectedRoom.Value = new Room { RoomID = { Value = 3 } }; + + Child = new MatchLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = MatchLeaderboardScope.Overall, + }; + }); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index a800b21bc9..12e7394c93 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -91,8 +91,7 @@ namespace osu.Game.Tests.Visual.Multiplayer break; case StopCountdownRequest: - multiplayerRoom.Countdown = null; - raiseRoomUpdated(); + clearRoomCountdown(); break; } }); @@ -244,14 +243,14 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("start countdown", () => multiplayerClient.Object.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely()); - AddUntilStep("countdown started", () => multiplayerRoom.Countdown != null); + AddUntilStep("countdown started", () => multiplayerRoom.ActiveCountdowns.Any()); AddStep("transfer host to local user", () => transferHost(localUser)); AddUntilStep("local user is host", () => multiplayerRoom.Host?.Equals(multiplayerClient.Object.LocalUser) == true); ClickButtonWhenEnabled(); checkLocalUserState(MultiplayerUserState.Ready); - AddAssert("countdown still active", () => multiplayerRoom.Countdown != null); + AddAssert("countdown still active", () => multiplayerRoom.ActiveCountdowns.Any()); } [Test] @@ -392,7 +391,13 @@ namespace osu.Game.Tests.Visual.Multiplayer private void setRoomCountdown(TimeSpan duration) { - multiplayerRoom.Countdown = new MatchStartCountdown { TimeRemaining = duration }; + multiplayerRoom.ActiveCountdowns.Add(new MatchStartCountdown { TimeRemaining = duration }); + raiseRoomUpdated(); + } + + private void clearRoomCountdown() + { + multiplayerRoom.ActiveCountdowns.Clear(); raiseRoomUpdated(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 80c356ec67..4fda4c1c50 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -22,8 +22,10 @@ namespace osu.Game.Tests.Visual.Multiplayer private MultiSpectatorLeaderboard leaderboard; [SetUpSteps] - public new void SetUpSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + AddStep("reset", () => { leaderboard?.RemoveAndDisposeImmediately(); @@ -118,6 +120,6 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time); private void assertCombo(int userId, int expectedCombo) - => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); + => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index 7df68392cf..13fde4fd72 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -13,9 +13,11 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; @@ -56,8 +58,12 @@ namespace osu.Game.Tests.Visual.Multiplayer importedBeatmapId = importedBeatmap.OnlineID; } - [SetUp] - public new void Setup() => Schedule(() => playingUsers.Clear()); + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("clear playing users", () => playingUsers.Clear()); + } [Test] public void TestDelayedStart() @@ -161,11 +167,11 @@ namespace osu.Game.Tests.Visual.Multiplayer sendFrames(PLAYER_1_ID, 40); sendFrames(PLAYER_2_ID, 20); - checkPaused(PLAYER_2_ID, true); - checkPausedInstant(PLAYER_1_ID, false); + waitUntilPaused(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); AddAssert("master clock still running", () => this.ChildrenOfType().Single().IsRunning); - checkPaused(PLAYER_1_ID, true); + waitUntilPaused(PLAYER_1_ID); AddUntilStep("master clock paused", () => !this.ChildrenOfType().Single().IsRunning); } @@ -177,13 +183,13 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send frames for one player only, both should remain paused. sendFrames(PLAYER_1_ID, 20); - checkPausedInstant(PLAYER_1_ID, true); - checkPausedInstant(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); // Send frames for the other player, both should now start playing. sendFrames(PLAYER_2_ID, 20); - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_1_ID); + checkRunningInstant(PLAYER_2_ID); } [Test] @@ -194,15 +200,15 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send frames for one player only, both should remain paused. sendFrames(PLAYER_1_ID, 1000); - checkPausedInstant(PLAYER_1_ID, true); - checkPausedInstant(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); // 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); - checkPausedInstant(PLAYER_2_ID, true); + checkRunningInstant(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); } [Test] @@ -214,26 +220,26 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send initial frames for both players. A few more for player 1. sendFrames(PLAYER_1_ID, 20); sendFrames(PLAYER_2_ID); - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_1_ID); + checkRunningInstant(PLAYER_2_ID); // Eventually player 2 will pause, player 1 must remain running. - checkPaused(PLAYER_2_ID, true); - checkPausedInstant(PLAYER_1_ID, false); + waitUntilPaused(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); // Eventually both players will run out of frames and should pause. - checkPaused(PLAYER_1_ID, true); - checkPausedInstant(PLAYER_2_ID, true); + waitUntilPaused(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); // Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused. sendFrames(PLAYER_1_ID, 20); - checkPausedInstant(PLAYER_2_ID, true); - checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); // Send more frames for the second player. Both should be playing sendFrames(PLAYER_2_ID, 20); - checkPausedInstant(PLAYER_2_ID, false); - checkPausedInstant(PLAYER_1_ID, false); + checkRunningInstant(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); } [Test] @@ -245,16 +251,16 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send initial frames for both players. A few more for player 1. sendFrames(PLAYER_1_ID, 1000); sendFrames(PLAYER_2_ID, 30); - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_1_ID); + checkRunningInstant(PLAYER_2_ID); // Eventually player 2 will run out of frames and should pause. - checkPaused(PLAYER_2_ID, true); + waitUntilPaused(PLAYER_2_ID); AddWaitStep("wait a few more frames", 10); // Send more frames for player 2. It should unpause. sendFrames(PLAYER_2_ID, 1000); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_2_ID); // Player 2 should catch up to player 1 after unpausing. waitForCatchup(PLAYER_2_ID); @@ -267,21 +273,28 @@ namespace osu.Game.Tests.Visual.Multiplayer start(new[] { PLAYER_1_ID, PLAYER_2_ID }); loadSpectateScreen(); + // With no frames, the synchronisation state will be TooFarAhead. + // In this state, all players should be muted. assertMuted(PLAYER_1_ID, true); assertMuted(PLAYER_2_ID, true); - sendFrames(PLAYER_1_ID); + // Send frames for both players, with more frames for player 2. + sendFrames(PLAYER_1_ID, 5); sendFrames(PLAYER_2_ID, 20); - checkPaused(PLAYER_1_ID, false); - assertOneNotMuted(); - checkPaused(PLAYER_1_ID, true); + // While both players are running, one of them should be un-muted. + waitUntilRunning(PLAYER_1_ID); + assertOnePlayerNotMuted(); + + // After player 1 runs out of frames, the un-muted player should always be player 2. + waitUntilPaused(PLAYER_1_ID); + waitUntilRunning(PLAYER_2_ID); assertMuted(PLAYER_1_ID, true); assertMuted(PLAYER_2_ID, false); sendFrames(PLAYER_1_ID, 100); waitForCatchup(PLAYER_1_ID); - checkPaused(PLAYER_2_ID, true); + waitUntilPaused(PLAYER_2_ID); assertMuted(PLAYER_1_ID, false); assertMuted(PLAYER_2_ID, true); @@ -314,13 +327,25 @@ namespace osu.Game.Tests.Visual.Multiplayer loadSpectateScreen(); sendFrames(PLAYER_1_ID, 300); - AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); - checkPaused(PLAYER_1_ID, false); + AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + waitUntilRunning(PLAYER_1_ID); sendFrames(PLAYER_2_ID, 300); AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType().Single().FrameStableClock.CurrentTime > 30000); } + [Test] + public void TestGameplayRateAdjust() + { + start(getPlayerIds(4), mods: new[] { new APIMod(new OsuModDoubleTime()) }); + + loadSpectateScreen(); + + sendFrames(getPlayerIds(4), 300); + + AddUntilStep("wait for correct track speed", () => Beatmap.Value.Track.Rate, () => Is.EqualTo(1.5)); + } + [Test] public void TestPlayersLeaveWhileSpectating() { @@ -353,12 +378,18 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// Tests spectating with a beatmap that has a high value. + /// + /// This test is not intended not to check the correct initial time value, but only to guard against + /// gameplay potentially getting stuck in a stopped state due to lead in time being present. /// [Test] public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000); /// /// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element). + /// + /// This test is not intended not to check the correct initial time value, but only to guard against + /// gameplay potentially getting stuck in a stopped state due to lead in time being present. /// [Test] public void TestIntroStoryboardElement() => testLeadIn(b => @@ -380,10 +411,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded); - AddWaitStep("wait for progression", 3); + AddUntilStep("wait for clock running", () => getInstance(PLAYER_1_ID).SpectatorPlayerClock.IsRunning); assertNotCatchingUp(PLAYER_1_ID); - assertRunning(PLAYER_1_ID); + waitUntilRunning(PLAYER_1_ID); } private void loadSpectateScreen(bool waitForPlayerLoad = true, Action? applyToBeatmap = null) @@ -403,7 +434,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId); - private void start(int[] userIds, int? beatmapId = null) + private void start(int[] userIds, int? beatmapId = null, APIMod[]? mods = null) { AddStep("start play", () => { @@ -412,10 +443,11 @@ namespace osu.Game.Tests.Visual.Multiplayer var user = new MultiplayerRoomUser(id) { User = new APIUser { Id = id }, + Mods = mods ?? Array.Empty(), }; - OnlinePlayDependencies.MultiplayerClient.AddUser(user.User, true); - SpectatorClient.SendStartPlay(id, beatmapId ?? importedBeatmapId); + OnlinePlayDependencies.MultiplayerClient.AddUser(user, true); + SpectatorClient.SendStartPlay(id, beatmapId ?? importedBeatmapId, mods); playingUsers.Add(user); } @@ -428,13 +460,17 @@ namespace osu.Game.Tests.Visual.Multiplayer { var user = playingUsers.Single(u => u.UserID == userId); - OnlinePlayDependencies.MultiplayerClient.RemoveUser(user.User.AsNonNull()); SpectatorClient.SendEndPlay(userId); + OnlinePlayDependencies.MultiplayerClient.RemoveUser(user.User.AsNonNull()); playingUsers.Remove(user); }); } + /// + /// Send new frames on behalf of a user. + /// Frames will last for count * 100 milliseconds. + /// private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count); private void sendFrames(int[] userIds, int count = 10) @@ -446,36 +482,47 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private void checkPaused(int userId, bool state) - => AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); - - private void checkPausedInstant(int userId, bool state) + private void checkRunningInstant(int userId) { - checkPaused(userId, state); + waitUntilRunning(userId); // Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time. // AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); } - private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1); + private void checkPausedInstant(int userId) + { + waitUntilPaused(userId); + + // Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time. + // AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + } + + private void assertOnePlayerNotMuted() => AddAssert(nameof(assertOnePlayerNotMuted), () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1); private void assertMuted(int userId, bool muted) - => AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted); + => AddAssert($"{nameof(assertMuted)}({userId}, {muted})", () => getInstance(userId).Mute == muted); private void assertRunning(int userId) - => AddAssert($"{userId} clock running", () => getInstance(userId).GameplayClock.IsRunning); + => AddAssert($"{nameof(assertRunning)}({userId})", () => getInstance(userId).SpectatorPlayerClock.IsRunning); + + private void waitUntilPaused(int userId) + => AddUntilStep($"{nameof(waitUntilPaused)}({userId})", () => !getPlayer(userId).ChildrenOfType().First().IsRunning); + + private void waitUntilRunning(int userId) + => AddUntilStep($"{nameof(waitUntilRunning)}({userId})", () => getPlayer(userId).ChildrenOfType().First().IsRunning); private void assertNotCatchingUp(int userId) - => AddAssert($"{userId} in sync", () => !getInstance(userId).GameplayClock.IsCatchingUp); + => AddAssert($"{nameof(assertNotCatchingUp)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp); private void waitForCatchup(int userId) - => AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp); + => AddUntilStep($"{nameof(waitForCatchup)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp); private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType().Single(); private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId); - private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType().Single(s => s.User?.Id == userId); + private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType().Single(s => s.User?.OnlineID == userId); private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index a2793acba7..6098a3e794 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -24,7 +24,6 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; @@ -50,7 +49,6 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayer : ScreenTestScene { private BeatmapManager beatmaps = null!; - private RulesetStore rulesets = null!; private BeatmapSetInfo importedSet = null!; private TestMultiplayerComponents multiplayerComponents = null!; @@ -64,8 +62,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -402,16 +400,18 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestPlayStartsWithCorrectBeatmapWhileAtSongSelect() { - createRoom(() => new Room + PlaylistItem? item = null; + createRoom(() => { - Name = { Value = "Test Room" }, - Playlist = + item = new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { - new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - } - } + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }; + return new Room + { + Name = { Value = "Test Room" }, + Playlist = { item } + }; }); pressReadyButton(); @@ -419,7 +419,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(multiplayerClient.ClientRoom?.Settings.PlaylistItemId); + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -440,16 +440,18 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestPlayStartsWithCorrectRulesetWhileAtSongSelect() { - createRoom(() => new Room + PlaylistItem? item = null; + createRoom(() => { - Name = { Value = "Test Room" }, - Playlist = + item = new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { - new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - } - } + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }; + return new Room + { + Name = { Value = "Test Room" }, + Playlist = { item } + }; }); pressReadyButton(); @@ -457,7 +459,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(multiplayerClient.ClientRoom?.Settings.PlaylistItemId); + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -478,16 +480,18 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestPlayStartsWithCorrectModsWhileAtSongSelect() { - createRoom(() => new Room + PlaylistItem? item = null; + createRoom(() => { - Name = { Value = "Test Room" }, - Playlist = + item = new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { - new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - } - } + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }; + return new Room + { + Name = { Value = "Test Room" }, + Playlist = { item } + }; }); pressReadyButton(); @@ -495,7 +499,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(multiplayerClient.ClientRoom?.Settings.PlaylistItemId); + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -627,7 +631,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); - AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Hidden); AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); @@ -667,7 +671,7 @@ namespace osu.Game.Tests.Visual.Multiplayer for (double i = 1000; i < TestResources.QUICK_BEATMAP_LENGTH; i += 1000) { double time = i; - AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.GameplayClock.CurrentTime > time); + AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.CurrentTime > time); } AddUntilStep("wait for results", () => multiplayerComponents.CurrentScreen is ResultsScreen); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index a98030e1e3..83e7ef6a81 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -3,7 +3,6 @@ #nullable disable -using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -13,23 +12,27 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiplayerMatchFooter : MultiplayerTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - Child = new PopoverContainer + base.SetUpSteps(); + + AddStep("create footer", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = new Container + Child = new PopoverContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = 50, - Child = new MultiplayerMatchFooter() - } - }; - }); + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 50, + Child = new MultiplayerMatchFooter() + } + }; + }); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index ab4f9c37b2..b87321c047 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -19,7 +19,6 @@ using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; -using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -47,7 +46,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray())); @@ -68,37 +67,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } - [Test] - public void TestBeatmapRevertedOnExitIfNoSelection() - { - BeatmapInfo selectedBeatmap = null; - - AddStep("select beatmap", - () => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.Where(beatmap => beatmap.Ruleset.OnlineID == new OsuRuleset().LegacyID).ElementAt(1))); - AddUntilStep("wait for selection", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("beatmap reverted", () => Beatmap.IsDefault); - } - - [Test] - public void TestModsRevertedOnExitIfNoSelection() - { - AddStep("change mods", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("mods reverted", () => SelectedMods.Value.Count == 0); - } - - [Test] - public void TestRulesetRevertedOnExitIfNoSelection() - { - AddStep("change ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("ruleset reverted", () => Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); - } - [Test] public void TestBeatmapConfirmed() { @@ -152,8 +120,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public new BeatmapCarousel Carousel => base.Carousel; - public TestMultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) - : base(room, null, beatmap, ruleset) + public TestMultiplayerMatchSongSelect(Room room) + : base(room) { } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 5d6a6c8104..9fc42dc68b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -4,6 +4,7 @@ #nullable disable using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -17,6 +18,8 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -24,6 +27,7 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Multiplayer; @@ -40,7 +44,6 @@ namespace osu.Game.Tests.Visual.Multiplayer private MultiplayerMatchSubScreen screen; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; public TestSceneMultiplayerMatchSubScreen() @@ -51,8 +54,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); @@ -60,16 +63,15 @@ namespace osu.Game.Tests.Visual.Multiplayer importedSet = beatmaps.GetAllUsableBeatmapSets().First(); } - [SetUp] - public new void Setup() => Schedule(() => - { - SelectedRoom.Value = new Room { Name = { Value = "Test Room" } }; - }); - [SetUpSteps] public void SetupSteps() { - AddStep("load match", () => LoadScreen(screen = new MultiplayerMatchSubScreen(SelectedRoom.Value))); + AddStep("load match", () => + { + SelectedRoom.Value = new Room { Name = { Value = "Test Room" } }; + LoadScreen(screen = new TestMultiplayerMatchSubScreen(SelectedRoom.Value)); + }); + AddUntilStep("wait for load", () => screen.IsCurrentScreen()); } @@ -283,5 +285,29 @@ namespace osu.Game.Tests.Visual.Multiplayer return lastItem.IsSelectedItem; }); } + + private class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen + { + [Resolved(canBeNull: true)] + [CanBeNull] + private IDialogOverlay dialogOverlay { get; set; } + + public TestMultiplayerMatchSubScreen(Room room) + : base(room) + { + } + + public override bool OnExiting(ScreenExitEvent e) + { + // For testing purposes allow the screen to exit without confirming on second attempt. + if (!ExitConfirmed && dialogOverlay?.CurrentDialog is ConfirmDiscardChangesDialog confirmDialog) + { + confirmDialog.PerformAction(); + return true; + } + + return base.OnExiting(e); + } + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index a70dfd78c5..edd1491865 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; @@ -368,12 +369,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { ParticipantsList? participantsList = null; - AddStep("create new list", () => Child = participantsList = new ParticipantsList + AddStep("create new list", () => Child = new OsuContextMenuContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Size = new Vector2(380, 0.7f) + RelativeSizeAxes = Axes.Both, + Child = participantsList = new ParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(380, 0.7f) + } }); AddUntilStep("wait for list to load", () => participantsList?.IsLoaded == true); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 5ee385810b..8dbad4e330 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -31,33 +31,33 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerPlaylist list; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private BeatmapInfo importedBeatmap; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } - [SetUp] - public new void Setup() => Schedule(() => - { - Child = list = new MultiplayerPlaylist - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.4f, 0.8f) - }; - }); - [SetUpSteps] - public new void SetUpSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + + AddStep("create list", () => + { + Child = list = new MultiplayerPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.4f, 0.8f) + }; + }); + AddStep("import beatmap", () => { beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index e709a955b3..63677ce378 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -29,15 +29,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerQueueList playlist; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private BeatmapInfo importedBeatmap; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -98,14 +97,23 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - public void TestCurrentItemDoesNotHaveDeleteButton() + public void TestSingleItemDoesNotHaveDeleteButton() + { + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + + assertDeleteButtonVisibility(0, false); + } + + [Test] + public void TestCurrentItemHasDeleteButtonIfNotSingle() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); addPlaylistItem(() => API.LocalUser.Value.OnlineID); - assertDeleteButtonVisibility(0, false); + assertDeleteButtonVisibility(0, true); assertDeleteButtonVisibility(1, true); AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index 91c87548c7..9b4cb722f3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -35,55 +35,58 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapSetInfo importedSet; private BeatmapManager beatmaps; - private RulesetStore rulesets; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); } - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - AvailabilityTracker.SelectedItem.BindTo(selectedItem); + base.SetUpSteps(); - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); - Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); - selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) + AddStep("create button", () => { - RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, - }; + AvailabilityTracker.SelectedItem.BindTo(selectedItem); - Child = new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, + }; + + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - spectateButton = new MultiplayerSpectateButton + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - }, - startControl = new MatchStartControl - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), + spectateButton = new MultiplayerSpectateButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + }, + startControl = new MatchStartControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + } } } - } - }; - }); + }; + }); + } [TestCase(MultiplayerRoomState.Open)] [TestCase(MultiplayerRoomState.WaitingForLoad)] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 88afe1ce7c..2eddf1a17e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -28,15 +28,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { private BeatmapManager manager; - private RulesetStore rulesets; - private TestPlaylistsSongSelect songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); var beatmapSet = TestResources.CreateTestBeatmapSetInfo(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs index 321e0c2c89..5bccabcf2f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs @@ -14,17 +14,21 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneStarRatingRangeDisplay : OnlinePlayTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room(); + base.SetUpSteps(); - Child = new StarRatingRangeDisplay + AddStep("create display", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }; - }); + SelectedRoom.Value = new Room(); + + Child = new StarRatingRangeDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + } [Test] public void TestRange([Values(0, 2, 3, 4, 6, 7)] double min, [Values(0, 2, 3, 4, 6, 7)] double max) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index d80537a2e5..ef2a431b8f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -30,7 +30,6 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneTeamVersus : ScreenTestScene { private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private TestMultiplayerComponents multiplayerComponents; @@ -40,8 +39,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } diff --git a/osu.Game.Tests/Visual/Navigation/TestEFToRealmMigration.cs b/osu.Game.Tests/Visual/Navigation/TestEFToRealmMigration.cs deleted file mode 100644 index c3559589ed..0000000000 --- a/osu.Game.Tests/Visual/Navigation/TestEFToRealmMigration.cs +++ /dev/null @@ -1,63 +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.Linq; -using System.Runtime.InteropServices; -using NUnit.Framework; -using osu.Framework; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Models; -using osu.Game.Scoring; -using osu.Game.Skinning; -using osu.Game.Tests.Resources; - -namespace osu.Game.Tests.Visual.Navigation -{ - public class TestEFToRealmMigration : OsuGameTestScene - { - public override void RecycleLocalStorage(bool isDisposing) - { - base.RecycleLocalStorage(isDisposing); - - if (isDisposing) - return; - - using (var outStream = LocalStorage.CreateFileSafely(DatabaseContextFactory.DATABASE_NAME)) - using (var stream = TestResources.OpenResource(DatabaseContextFactory.DATABASE_NAME)) - stream.CopyTo(outStream); - } - - [SetUp] - public void SetUp() - { - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && RuntimeInformation.OSArchitecture == Architecture.Arm64) - Assert.Ignore("EF-to-realm migrations are not supported on M1 ARM architectures."); - } - - public override void SetUpSteps() - { - // base SetUpSteps are executed before the above SetUp, therefore early-return to allow ignoring test properly. - // attempting to ignore here would yield a TargetInvocationException instead. - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && RuntimeInformation.OSArchitecture == Architecture.Arm64) - return; - - base.SetUpSteps(); - } - - [Test] - public void TestMigration() - { - // Numbers are taken from the test database (see commit f03de16ee5a46deac3b5f2ca1edfba5c4c5dca7d). - AddAssert("Check beatmaps", () => Game.Dependencies.Get().Run(r => r.All().Count(s => !s.Protected) == 1)); - AddAssert("Check skins", () => Game.Dependencies.Get().Run(r => r.All().Count(s => !s.Protected) == 1)); - AddAssert("Check scores", () => Game.Dependencies.Get().Run(r => r.All().Count() == 1)); - - // One extra file is created during realm migration / startup due to the circles intro import. - AddAssert("Check files", () => Game.Dependencies.Get().Run(r => r.All().Count() == 271)); - } - } -} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs b/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs index 4ca55e8744..010ed23c9b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestEditDefaultSkin() { - AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.DEFAULT_SKIN); + AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.ARGON_SKIN); AddStep("open settings", () => { Game.Settings.Show(); }); @@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("open skin editor", () => skinEditor.Show()); // Until step required as the skin editor may take time to load (and an extra scheduled frame for the mutable part). - AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.DEFAULT_SKIN); + AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.ARGON_SKIN); AddAssert("is not protected", () => skinManager.CurrentSkinInfo.Value.PerformRead(s => !s.Protected)); AddUntilStep("export button enabled", () => Game.Settings.ChildrenOfType().SingleOrDefault()?.Enabled.Value == true); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.cs index f4078676c9..fe26d59812 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.cs @@ -26,16 +26,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestImportantNotificationDoesntInterruptSetup() { AddStep("post important notification", () => Game.Notifications.Post(new SimpleNotification { Text = "Important notification" })); - AddAssert("no notification posted", () => Game.Notifications.UnreadCount.Value == 0); AddAssert("first-run setup still visible", () => Game.FirstRunOverlay.State.Value == Visibility.Visible); - - AddUntilStep("finish first-run setup", () => - { - Game.FirstRunOverlay.NextButton.TriggerClick(); - return Game.FirstRunOverlay.State.Value == Visibility.Hidden; - }); - AddWaitStep("wait for post delay", 5); - AddAssert("notifications shown", () => Game.Notifications.State.Value == Visibility.Visible); AddAssert("notification posted", () => Game.Notifications.UnreadCount.Value == 1); } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index cf62c73ad4..6070b1456f 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -25,6 +25,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Skinning; +using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { @@ -79,6 +80,16 @@ namespace osu.Game.Tests.Visual.Navigation [Resolved] private OsuGameBase gameBase { get; set; } + [Test] + public void TestCursorHidesWhenIdle() + { + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("wait until idle", () => Game.IsIdle.Value); + AddUntilStep("menu cursor hidden", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("menu cursor shown", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 1); + } + [Test] public void TestNullRulesetHandled() { diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 5e76fe1519..003cec0d07 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -9,8 +9,10 @@ using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; @@ -92,6 +94,31 @@ namespace osu.Game.Tests.Visual.Navigation returnToMenu(); } + [Test] + public void TestFromSongSelectWithFilter([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + AddStep("filter to nothing", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).FilterControl.CurrentTextSearch.Value = "fdsajkl;fgewq"); + AddUntilStep("wait for no results", () => Beatmap.IsDefault); + + var firstImport = importScore(1, new CatchRuleset().RulesetInfo); + presentAndConfirm(firstImport, type); + } + + [Test] + public void TestFromSongSelectWithConvertRulesetChange([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + AddStep("set convert to false", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + var firstImport = importScore(1, new CatchRuleset().RulesetInfo); + presentAndConfirm(firstImport, type); + } + [Test] public void TestFromSongSelect([Values] ScorePresentType type) { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index a61352f954..8fce43f9b0 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -26,6 +26,7 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; @@ -45,6 +46,57 @@ namespace osu.Game.Tests.Visual.Navigation private Vector2 optionsButtonPosition => Game.ToScreenSpace(new Vector2(click_padding, click_padding)); + [TestCase(false)] + [TestCase(true)] + public void TestConfirmationRequiredToDiscardPlaylist(bool withPlaylistItemAdded) + { + Screens.OnlinePlay.Playlists.Playlists playlistScreen = null; + + AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + + PushAndConfirm(() => playlistScreen = new Screens.OnlinePlay.Playlists.Playlists()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddStep("open create screen", () => + { + InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + if (withPlaylistItemAdded) + { + AddUntilStep("wait for settings displayed", + () => (playlistScreen.CurrentSubScreen as PlaylistsRoomSubScreen)?.ChildrenOfType().SingleOrDefault()?.State.Value == Visibility.Visible); + + AddStep("edit playlist", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + + AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); + + AddStep("add item", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for return to playlist screen", () => playlistScreen.CurrentSubScreen is PlaylistsRoomSubScreen); + + pushEscape(); + AddAssert("confirmation dialog shown", () => Game.ChildrenOfType().Single().CurrentDialog is not null); + + AddStep("confirm exit", () => InputManager.Key(Key.Enter)); + + AddAssert("dialog dismissed", () => Game.ChildrenOfType().Single().CurrentDialog == null); + + exitViaEscapeAndConfirm(); + } + else + { + pushEscape(); + AddAssert("confirmation dialog not shown", () => Game.ChildrenOfType().Single().CurrentDialog == null); + + exitViaEscapeAndConfirm(); + } + } + [Test] public void TestExitSongSelectWithEscape() { @@ -74,14 +126,14 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("set filter again", () => songSelect.ChildrenOfType().Single().Current.Value = "test"); AddStep("open collections dropdown", () => { - InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); + InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("press back once", () => InputManager.Click(MouseButton.Button1)); AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); AddAssert("collections dropdown closed", () => songSelect - .ChildrenOfType().Single() + .ChildrenOfType().Single() .ChildrenOfType.DropdownMenu>().Single().State == MenuState.Closed); AddStep("press back a second time", () => InputManager.Click(MouseButton.Button1)); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs index cd53bf3af5..552eb82419 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.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.Testing; @@ -13,7 +11,7 @@ namespace osu.Game.Tests.Visual.Navigation { public class TestSceneStartupImport : OsuGameTestScene { - private string importFilename; + private string? importFilename; protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename }); diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs index f5c7ee2f19..2879536034 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("force save config", () => Game.LocalConfig.Save()); - AddStep("remove game", () => Remove(Game)); + AddStep("remove game", () => Remove(Game, true)); AddStep("create game again", CreateGame); diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 8ef120d252..2c9b34e4a7 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -35,6 +35,8 @@ namespace osu.Game.Tests.Visual.Online private OsuConfigManager localConfig; + private bool returnCursorOnResponse; + [BackgroundDependencyLoader] private void load() { @@ -61,6 +63,7 @@ namespace osu.Game.Tests.Visual.Online searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse { BeatmapSets = setsForResponse, + Cursor = returnCursorOnResponse ? new Cursor() : null, }); return true; @@ -106,7 +109,7 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); @@ -127,10 +130,10 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); assertAllCardsOfType(100); - AddStep("show more results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 30).ToArray())); + AddStep("show more results", () => fetchFor(getManyBeatmaps(30).ToArray())); assertAllCardsOfType(30); } @@ -139,7 +142,7 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); assertAllCardsOfType(100); setCardSize(BeatmapCardSize.Extra, viaConfig); @@ -161,7 +164,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("fetch for 0 beatmaps", () => fetchFor()); placeholderShown(); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); AddUntilStep("wait for loaded", () => this.ChildrenOfType().Count() == 100); AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); @@ -180,6 +183,32 @@ namespace osu.Game.Tests.Visual.Online }); } + /// + /// During pagination, the first beatmap of the second page may be a duplicate of the last beatmap from the previous page. + /// This is currently the case with osu!web API due to ES relevance score's presence in the response cursor. + /// See: https://github.com/ppy/osu-web/issues/9270 + /// + [Test] + public void TestDuplicatedBeatmapOnlyShowsOnce() + { + APIBeatmapSet beatmapSet = null; + + AddStep("show many results", () => + { + beatmapSet = CreateAPIBeatmapSet(Ruleset.Value); + beatmapSet.Title = "last beatmap of first page"; + + fetchFor(getManyBeatmaps(49).Append(beatmapSet).ToArray(), true); + }); + AddUntilStep("wait for loaded", () => this.ChildrenOfType().Count() == 50); + + AddStep("set next page", () => setSearchResponse(getManyBeatmaps(49).Prepend(beatmapSet).ToArray(), false)); + AddStep("scroll to end", () => overlay.ChildrenOfType().Single().ScrollToEnd()); + AddUntilStep("wait for loaded", () => this.ChildrenOfType().Count() == 99); + + AddAssert("beatmap not duplicated", () => overlay.ChildrenOfType().Count(c => c.BeatmapSet.Equals(beatmapSet)) == 1); + } + [Test] public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults() { @@ -336,15 +365,25 @@ namespace osu.Game.Tests.Visual.Online private static int searchCount; - private void fetchFor(params APIBeatmapSet[] beatmaps) + private APIBeatmapSet[] getManyBeatmaps(int count) => Enumerable.Range(0, count).Select(_ => CreateAPIBeatmapSet(Ruleset.Value)).ToArray(); + + private void fetchFor(params APIBeatmapSet[] beatmaps) => fetchFor(beatmaps, false); + + private void fetchFor(APIBeatmapSet[] beatmaps, bool hasNextPage) { - setsForResponse.Clear(); - setsForResponse.AddRange(beatmaps); + setSearchResponse(beatmaps, hasNextPage); // trigger arbitrary change for fetching. searchControl.Query.Value = $"search {searchCount++}"; } + private void setSearchResponse(APIBeatmapSet[] beatmaps, bool hasNextPage) + { + setsForResponse.Clear(); + setsForResponse.AddRange(beatmaps); + returnCursorOnResponse = hasNextPage; + } + private void setRankAchievedFilter(ScoreRank[] ranks) { AddStep($"set Rank Achieved filter to [{string.Join(',', ranks)}]", () => diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 416e8aebcc..bb4823fb1d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -16,6 +16,10 @@ using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Scores; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Select.Details; using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online @@ -34,6 +38,9 @@ namespace osu.Game.Tests.Visual.Online [Resolved] private IRulesetStore rulesets { get; set; } + [SetUp] + public void SetUp() => Schedule(() => SelectedMods.Value = Array.Empty()); + [Test] public void TestLoading() { @@ -205,6 +212,21 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestSelectedModsDontAffectStatistics() + { + AddStep("show map", () => overlay.ShowBeatmapSet(getBeatmapSet())); + AddAssert("AR displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == BeatmapsetsStrings.ShowStatsAr).Value == (0, null)); + AddStep("set AR10 diff adjust", () => SelectedMods.Value = new[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 10 } + } + }); + AddAssert("AR still displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == BeatmapsetsStrings.ShowStatsAr).Value == (0, null)); + } + [Test] public void TestHide() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index 4c39dc34d5..c5ac3dd442 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -108,6 +108,7 @@ namespace osu.Game.Tests.Visual.Online Version = "2018.712.0", DisplayVersion = "2018.712.0", UpdateStream = streams[OsuGameBase.CLIENT_STREAM_NAME], + CreatedAt = new DateTime(2018, 7, 12), ChangelogEntries = new List { new APIChangelogEntry @@ -171,6 +172,7 @@ namespace osu.Game.Tests.Visual.Online { Version = "2019.920.0", DisplayVersion = "2019.920.0", + CreatedAt = new DateTime(2019, 9, 20), UpdateStream = new APIUpdateStream { Name = "Test", diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs new file mode 100644 index 0000000000..bf01d3b0ac --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs @@ -0,0 +1,257 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneCommentActions : OsuManualInputManagerTestScene + { + private Container content = null!; + protected override Container Content => content; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [Cached(typeof(IDialogOverlay))] + private readonly DialogOverlay dialogOverlay = new DialogOverlay(); + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private CommentsContainer commentsContainer = null!; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.AddRange(new Drawable[] + { + content = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both + }, + dialogOverlay + }); + } + + [SetUpSteps] + public void SetUp() + { + Schedule(() => + { + API.Login("test", "test"); + Child = commentsContainer = new CommentsContainer(); + }); + } + + [Test] + public void TestNonOwnCommentCantBeDeleted() + { + addTestComments(); + + AddUntilStep("First comment has button", () => + { + var comments = this.ChildrenOfType(); + var ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null && ourComment.ChildrenOfType().Any(x => x.Text == "Delete"); + }); + + AddAssert("Second doesn't", () => + { + var comments = this.ChildrenOfType(); + var ourComment = comments.Single(x => x.Comment.Id == 2); + return ourComment.ChildrenOfType().All(x => x.Text != "Delete"); + }); + } + + private readonly ManualResetEventSlim deletionPerformed = new ManualResetEventSlim(); + + [Test] + public void TestDeletion() + { + DrawableComment? ourComment = null; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null; + }); + AddStep("It has delete button", () => + { + var btn = ourComment.ChildrenOfType().Single(x => x.Text == "Delete"); + InputManager.MoveMouseTo(btn); + }); + AddStep("Click delete button", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("Setup request handling", () => + { + deletionPerformed.Reset(); + + dummyAPI.HandleRequest = request => + { + if (!(request is CommentDeleteRequest req)) + return false; + + if (req.CommentId != 1) + return false; + + CommentBundle cb = new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 2, + Message = "This is a comment by another user", + UserId = API.LocalUser.Value.Id + 1, + CreatedAt = DateTimeOffset.Now, + User = new APIUser + { + Id = API.LocalUser.Value.Id + 1, + Username = "Another user" + } + }, + }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + + Task.Run(() => + { + deletionPerformed.Wait(10000); + req.TriggerSuccess(cb); + }); + + return true; + }; + }); + AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); + + AddAssert("Loading spinner shown", () => commentsContainer.ChildrenOfType().Any(d => d.IsPresent)); + + AddStep("Complete request", () => deletionPerformed.Set()); + + AddUntilStep("Comment is deleted locally", () => this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted); + } + + [Test] + public void TestDeletionFail() + { + DrawableComment? ourComment = null; + bool delete = false; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null; + }); + AddStep("It has delete button", () => + { + var btn = ourComment.ChildrenOfType().Single(x => x.Text == "Delete"); + InputManager.MoveMouseTo(btn); + }); + AddStep("Click delete button", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("Setup request handling", () => + { + dummyAPI.HandleRequest = request => + { + if (request is not CommentDeleteRequest req) + return false; + + req.TriggerFailure(new Exception()); + delete = true; + return false; + }; + }); + AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); + AddUntilStep("Deletion requested", () => delete); + AddUntilStep("Comment is available", () => + { + return !this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted; + }); + AddAssert("Loading spinner hidden", () => + { + return ourComment.ChildrenOfType().All(d => !d.IsPresent); + }); + AddAssert("Actions available", () => + { + return ourComment.ChildrenOfType().Single(x => x.Name == @"Actions buttons").IsPresent; + }); + } + + private void addTestComments() + { + AddStep("set up response", () => + { + CommentBundle cb = new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "This is our comment", + UserId = API.LocalUser.Value.Id, + CreatedAt = DateTimeOffset.Now, + User = API.LocalUser.Value, + }, + new Comment + { + Id = 2, + Message = "This is a comment by another user", + UserId = API.LocalUser.Value.Id + 1, + CreatedAt = DateTimeOffset.Now, + User = new APIUser + { + Id = API.LocalUser.Value.Id + 1, + Username = "Another user" + } + }, + }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + setUpCommentsResponse(cb); + }); + + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + } + + private void setUpCommentsResponse(CommentBundle commentBundle) + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetCommentsRequest getCommentsRequest)) + return false; + + getCommentsRequest.TriggerSuccess(commentBundle); + return true; + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs index fdcde0f2a5..4185d56833 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs @@ -3,6 +3,8 @@ #nullable disable +using osu.Framework.Graphics; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osuTK; @@ -12,9 +14,15 @@ namespace osu.Game.Tests.Visual.Online { public TestSceneExternalLinkButton() { - Child = new ExternalLinkButton("https://osu.ppy.sh/home") + Child = new OsuContextMenuContainer { - Size = new Vector2(50) + RelativeSizeAxes = Axes.Both, + Child = new ExternalLinkButton("https://osu.ppy.sh/home") + { + Size = new Vector2(50), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index c5c61cdd72..5454c87dff 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -55,7 +55,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3103765, IsOnline = true, Statistics = new UserStatistics { GlobalRank = 1111 }, - Country = new Country { FlagName = "JP" }, + CountryCode = CountryCode.JP, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }, new APIUser @@ -64,7 +64,7 @@ namespace osu.Game.Tests.Visual.Online Id = 2, IsOnline = false, Statistics = new UserStatistics { GlobalRank = 2222 }, - Country = new Country { FlagName = "AU" }, + CountryCode = CountryCode.AU, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", IsSupporter = true, SupportLevel = 3, @@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.Online { Username = "Evast", Id = 8195163, - Country = new Country { FlagName = "BY" }, + CountryCode = CountryCode.BY, CoverUrl = "https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", IsOnline = false, LastVisit = DateTimeOffset.Now diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs index 10d9a5664e..5579ecedbd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.Online { if (selected.Text == mod.Acronym) { - selectedMods.Remove(selected); + selectedMods.Remove(selected, true); break; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs index e7d799222a..6a39db4870 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.Online public TestSceneRankingsCountryFilter() { - var countryBindable = new Bindable(); + var countryBindable = new Bindable(); AddRange(new Drawable[] { @@ -56,20 +56,12 @@ namespace osu.Game.Tests.Visual.Online } }); - var country = new Country - { - FlagName = "BY", - FullName = "Belarus" - }; - var unknownCountry = new Country - { - FlagName = "CK", - FullName = "Cook Islands" - }; + const CountryCode country = CountryCode.BY; + const CountryCode unknown_country = CountryCode.CK; AddStep("Set country", () => countryBindable.Value = country); - AddStep("Set null country", () => countryBindable.Value = null); - AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + AddStep("Set default country", () => countryBindable.Value = default); + AddStep("Set country with no flag", () => countryBindable.Value = unknown_country); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs index c8f08d70be..c776cfe377 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Online public TestSceneRankingsHeader() { - var countryBindable = new Bindable(); + var countryBindable = new Bindable(); var ruleset = new Bindable(); var scope = new Bindable(); @@ -30,21 +30,12 @@ namespace osu.Game.Tests.Visual.Online Ruleset = { BindTarget = ruleset } }); - var country = new Country - { - FlagName = "BY", - FullName = "Belarus" - }; - - var unknownCountry = new Country - { - FlagName = "CK", - FullName = "Cook Islands" - }; + const CountryCode country = CountryCode.BY; + const CountryCode unknown_country = CountryCode.CK; AddStep("Set country", () => countryBindable.Value = country); AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); - AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + AddStep("Set country with no flag", () => countryBindable.Value = unknown_country); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs index 62dad7b458..5476049882 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tests.Visual.Online private TestRankingsOverlay rankingsOverlay; - private readonly Bindable countryBindable = new Bindable(); + private readonly Bindable countryBindable = new Bindable(); private readonly Bindable scope = new Bindable(); [SetUp] @@ -48,15 +48,15 @@ namespace osu.Game.Tests.Visual.Online public void TestFlagScopeDependency() { AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); - AddAssert("Check country is Null", () => countryBindable.Value == null); - AddStep("Set country", () => countryBindable.Value = us_country); + AddAssert("Check country is default", () => countryBindable.IsDefault); + AddStep("Set country", () => countryBindable.Value = CountryCode.US); AddAssert("Check scope is Performance", () => scope.Value == RankingsScope.Performance); } [Test] public void TestShowCountry() { - AddStep("Show US", () => rankingsOverlay.ShowCountry(us_country)); + AddStep("Show US", () => rankingsOverlay.ShowCountry(CountryCode.US)); } private void loadRankingsOverlay() @@ -69,15 +69,9 @@ namespace osu.Game.Tests.Visual.Online }; } - private static readonly Country us_country = new Country - { - FlagName = "US", - FullName = "United States" - }; - private class TestRankingsOverlay : RankingsOverlay { - public new Bindable Country => base.Country; + public new Bindable Country => base.Country; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs index e357b0fffc..81b76d19ac 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs @@ -57,8 +57,7 @@ namespace osu.Game.Tests.Visual.Online { new CountryStatistics { - Country = new Country { FlagName = "US", FullName = "United States" }, - FlagName = "US", + Code = CountryCode.US, ActiveUsers = 2_972_623, PlayCount = 3_086_515_743, RankedScore = 449_407_643_332_546, @@ -66,8 +65,7 @@ namespace osu.Game.Tests.Visual.Online }, new CountryStatistics { - Country = new Country { FlagName = "RU", FullName = "Russian Federation" }, - FlagName = "RU", + Code = CountryCode.RU, ActiveUsers = 1_609_989, PlayCount = 1_637_052_841, RankedScore = 221_660_827_473_004, @@ -86,7 +84,7 @@ namespace osu.Game.Tests.Visual.Online User = new APIUser { Username = "first active user", - Country = new Country { FlagName = "JP" }, + CountryCode = CountryCode.JP, Active = true, }, Accuracy = 0.9972, @@ -106,7 +104,7 @@ namespace osu.Game.Tests.Visual.Online User = new APIUser { Username = "inactive user", - Country = new Country { FlagName = "AU" }, + CountryCode = CountryCode.AU, Active = false, }, Accuracy = 0.9831, @@ -126,7 +124,7 @@ namespace osu.Game.Tests.Visual.Online User = new APIUser { Username = "second active user", - Country = new Country { FlagName = "PL" }, + CountryCode = CountryCode.PL, Active = true, }, Accuracy = 0.9584, diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index 16a34e996f..a0f76c4e14 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -18,6 +18,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Users; using osuTK.Graphics; @@ -140,27 +141,36 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("best score not displayed", () => scoresContainer.ChildrenOfType().Count() == 1); } - private int onlineID = 1; + [Test] + public void TestUnprocessedPP() + { + AddStep("Load scores with unprocessed PP", () => + { + var allScores = createScores(); + allScores.Scores[0].PP = null; + allScores.UserScore = createUserBest(); + allScores.UserScore.Score.PP = null; + scoresContainer.Scores = allScores; + }); + } + + private ulong onlineID = 1; private APIScoresCollection createScores() { var scores = new APIScoresCollection { - Scores = new List + Scores = new List { - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, Mods = new[] { @@ -175,19 +185,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234567890, Accuracy = 1, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 4608074, Username = @"Skycries", - Country = new Country - { - FullName = @"Brazil", - FlagName = @"BR", - }, + CountryCode = CountryCode.BR, }, Mods = new[] { @@ -201,19 +207,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234789, Accuracy = 0.9997, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 1014222, Username = @"eLy", - Country = new Country - { - FullName = @"Japan", - FlagName = @"JP", - }, + CountryCode = CountryCode.JP, }, Mods = new[] { @@ -226,19 +228,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 12345678, Accuracy = 0.9854, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 1541390, Username = @"Toukai", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, Mods = new[] { @@ -250,19 +248,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234567, Accuracy = 0.8765, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 7151382, Username = @"Mayuri Hana", - Country = new Country - { - FullName = @"Thailand", - FlagName = @"TH", - }, + CountryCode = CountryCode.TH, }, Rank = ScoreRank.D, PP = 160, @@ -273,15 +267,26 @@ namespace osu.Game.Tests.Visual.Online } }; + const int initial_great_count = 2000; + const int initial_tick_count = 100; + + int greatCount = initial_great_count; + int tickCount = initial_tick_count; + foreach (var s in scores.Scores) { - s.Statistics = new Dictionary + s.Statistics = new Dictionary { - { "count_300", RNG.Next(2000) }, - { "count_100", RNG.Next(2000) }, - { "count_50", RNG.Next(2000) }, - { "count_miss", RNG.Next(2000) } + { HitResult.Great, greatCount }, + { HitResult.LargeTickHit, tickCount }, + { HitResult.Ok, RNG.Next(100) }, + { HitResult.Meh, RNG.Next(100) }, + { HitResult.Miss, initial_great_count - greatCount }, + { HitResult.LargeTickMiss, initial_tick_count - tickCount }, }; + + greatCount -= 100; + tickCount -= RNG.Next(1, 5); } return scores; @@ -289,19 +294,15 @@ namespace osu.Game.Tests.Visual.Online private APIScoreWithPosition createUserBest() => new APIScoreWithPosition { - Score = new APIScore + Score = new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 7151382, Username = @"Mayuri Hana", - Country = new Country - { - FullName = @"Thailand", - FlagName = @"TH", - }, + CountryCode = CountryCode.TH, }, Rank = ScoreRank.D, PP = 160, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index fff40b3c74..2a70fd7df3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"flyte", Id = 3103765, - Country = new Country { FlagName = @"JP" }, + CountryCode = CountryCode.JP, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg", Status = { Value = new UserStatusOnline() } }) { Width = 300 }, @@ -68,7 +68,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"peppy", Id = 2, - Country = new Country { FlagName = @"AU" }, + CountryCode = CountryCode.AU, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", IsSupporter = true, SupportLevel = 3, @@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"Evast", Id = 8195163, - Country = new Country { FlagName = @"BY" }, + CountryCode = CountryCode.BY, CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", IsOnline = false, LastVisit = DateTimeOffset.Now diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index ad3215b1ef..caa2d2571d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"Somebody", Id = 1, - Country = new Country { FullName = @"Alien" }, + CountryCode = CountryCode.Unknown, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", JoinDate = DateTimeOffset.Now.AddDays(-1), LastVisit = DateTimeOffset.Now, @@ -82,7 +82,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"peppy", Id = 2, IsSupporter = true, - Country = new Country { FullName = @"Australia", FlagName = @"AU" }, + CountryCode = CountryCode.AU, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg" })); @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"flyte", Id = 3103765, - Country = new Country { FullName = @"Japan", FlagName = @"JP" }, + CountryCode = CountryCode.JP, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" })); @@ -99,7 +99,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"BanchoBot", Id = 3, IsBot = true, - Country = new Country { FullName = @"Saint Helena", FlagName = @"SH" }, + CountryCode = CountryCode.SH, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg" })); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs index 163f0e62df..7875a9dfbc 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs @@ -83,6 +83,20 @@ namespace osu.Game.Tests.Visual.Online Beatmap = dummyBeatmap, }, new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.BeatmapsetApprove, + Approval = BeatmapApproval.Approved, + Beatmapset = dummyBeatmap, + }, + new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.BeatmapsetApprove, + Approval = BeatmapApproval.Loved, + Beatmapset = dummyBeatmap, + }, + new APIRecentActivity { User = dummyUser, Type = RecentActivityType.BeatmapsetApprove, @@ -90,6 +104,13 @@ namespace osu.Game.Tests.Visual.Online Beatmapset = dummyBeatmap, }, new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.BeatmapsetApprove, + Approval = BeatmapApproval.Ranked, + Beatmapset = dummyBeatmap, + }, + new APIRecentActivity { User = dummyUser, Type = RecentActivityType.BeatmapsetDelete, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs index fa28df3061..4bbb72c862 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs @@ -7,6 +7,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -21,7 +22,7 @@ namespace osu.Game.Tests.Visual.Online { public TestSceneUserProfileScores() { - var firstScore = new APIScore + var firstScore = new SoloScoreInfo { PP = 1047.21, Rank = ScoreRank.SH, @@ -34,7 +35,7 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "Extreme" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, Mods = new[] { new APIMod { Acronym = new OsuModHidden().Acronym }, @@ -44,7 +45,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.9813 }; - var secondScore = new APIScore + var secondScore = new SoloScoreInfo { PP = 134.32, Rank = ScoreRank.A, @@ -57,7 +58,7 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "[4K] Regret" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, Mods = new[] { new APIMod { Acronym = new OsuModHardRock().Acronym }, @@ -66,7 +67,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.998546 }; - var thirdScore = new APIScore + var thirdScore = new SoloScoreInfo { PP = 96.83, Rank = ScoreRank.S, @@ -79,11 +80,11 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "Insane" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, Accuracy = 0.9726 }; - var noPPScore = new APIScore + var noPPScore = new SoloScoreInfo { Rank = ScoreRank.B, Beatmap = new APIBeatmap @@ -95,7 +96,24 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "[4K] Cataclysmic Hypernova" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, + Accuracy = 0.55879 + }; + + var unprocessedPPScore = new SoloScoreInfo + { + Rank = ScoreRank.B, + Beatmap = new APIBeatmap + { + BeatmapSet = new APIBeatmapSet + { + Title = "C18H27NO3(extend)", + Artist = "Team Grimoire", + }, + DifficultyName = "[4K] Cataclysmic Hypernova", + Status = BeatmapOnlineStatus.Ranked, + }, + EndedAt = DateTimeOffset.Now, Accuracy = 0.55879 }; @@ -112,6 +130,7 @@ namespace osu.Game.Tests.Visual.Online new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(firstScore)), new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(secondScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(noPPScore)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unprocessedPPScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(firstScore, 0.97)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)), diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index 39432ee059..863b352618 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -189,6 +189,16 @@ Line after image"; }); } + [Test] + public void TestFlag() + { + AddStep("Add flag", () => + { + markdownContainer.CurrentPath = @"https://dev.ppy.sh"; + markdownContainer.Text = "::{flag=\"AU\"}:: ::{flag=\"ZZ\"}::"; + }); + } + private class TestMarkdownContainer : WikiMarkdownContainer { public LinkInline Link; diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 8889cb3e37..27cd74bb1f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Net; using NUnit.Framework; using osu.Game.Online.API; @@ -25,24 +26,40 @@ namespace osu.Game.Tests.Visual.Online public void TestMainPage() { setUpWikiResponse(responseMainPage); - AddStep("Show Main Page", () => wiki.Show()); + AddStep("Show main page", () => wiki.Show()); } [Test] public void TestArticlePage() { setUpWikiResponse(responseArticlePage); - AddStep("Show Article Page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + AddStep("Show article page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + } + + [Test] + public void TestRedirection() + { + const string redirection_path = "Redirection_path_for_article"; + + setUpWikiResponse(responseArticlePage, redirection_path); + AddStep("Show article page", () => wiki.ShowPage(redirection_path)); + + AddUntilStep("Current page is article", () => wiki.Header.Current.Value == "Formatting"); + + setUpWikiResponse(responseArticleParentPage); + AddStep("Show parent page", () => wiki.Header.ShowParentPage?.Invoke()); + + AddUntilStep("Current page is parent", () => wiki.Header.Current.Value == "Article styling criteria"); } [Test] public void TestErrorPage() { - setUpWikiResponse(null, true); + setUpWikiResponse(responseArticlePage); AddStep("Show Error Page", () => wiki.ShowPage("Error")); } - private void setUpWikiResponse(APIWikiPage r, bool isFailed = false) + private void setUpWikiResponse(APIWikiPage r, string redirectionPath = null) => AddStep("set up response", () => { dummyAPI.HandleRequest = request => @@ -50,10 +67,13 @@ namespace osu.Game.Tests.Visual.Online if (!(request is GetWikiRequest getWikiRequest)) return false; - if (isFailed) - getWikiRequest.TriggerFailure(new WebException()); - else + if (getWikiRequest.Path.Equals(r.Path, StringComparison.OrdinalIgnoreCase) || + getWikiRequest.Path.Equals(redirectionPath, StringComparison.OrdinalIgnoreCase)) + { getWikiRequest.TriggerSuccess(r); + } + else + getWikiRequest.TriggerFailure(new WebException()); return true; }; @@ -80,7 +100,19 @@ namespace osu.Game.Tests.Visual.Online Locale = "en", Subtitle = "Article styling criteria", Markdown = - "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\n\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119 \"IETF Tools\") to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | اَلْعَرَبِيَّةُ |\n| `be.md` | Belarusian | Беларуская мова |\n| `bg.md` | Bulgarian | Български |\n| `cs.md` | Czech | Česky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `gr.md` | Greek | Ελληνικά |\n| `es.md` | Spanish | Español |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Français |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | 日本語 |\n| `ko.md` | Korean | 한국어 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Português |\n| `pt-br.md` | Brazilian Portuguese | Português (Brasil) |\n| `ro.md` | Romanian | Română |\n| `ru.md` | Russian | Русский |\n| `sk.md` | Slovak | Slovenčina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | ไทย |\n| `tr.md` | Turkish | Türkçe |\n| `uk.md` | Ukrainian | Українська мова |\n| `vi.md` | Vietnamese | Tiếng Việt |\n| `zh.md` | Chinese (Simplified) | 简体中文 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | 繁體中文(台灣) |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n### Content parity\n\nTranslations are subject to strict content parity with their English article, in the sense that they must have the same message, regardless of grammar and syntax. Any changes to the translations' meanings must be accompanied by equivalent changes to the English article.\n\nThere are some cases where the content is allowed to differ:\n\n- Articles originally written in a language other than English (in this case, English should act as the translation)\n- Explanations of English words that are common terms in the osu! community\n- External links\n- Tags\n- Subcommunity-specific explanations\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example \"YAML Wikipedia article\") and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nTranslated articles that are outdated must use the `outdated` tag when the English variant is updated. English articles may also become outdated when the content they contain is misleading or no longer relevant. This tag must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals) \"Wikipedia\") for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}`*\n\n*For {description}, see: {article} and {article}`*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnotes), must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Emphasising\n\n### Bold\n\nBold must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nNames of work or video games should be italicised. osu!—the game—is exempt from this.\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings \"GitHub\") and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\n\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents. They cannot be linked to directly either.*\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game Modifiers](/wiki/Game_Modifiers)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game Modifiers][game mods link]\n\n[game mods link]: /wiki/Game_Modifiers\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#-pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/Developers)\n[Developers](/wiki/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Beatmap_Editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`§`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria § Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficuty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The link text should be the title of the page it is linking to. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory \"Wikipedia\")*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![](/wiki/shared/flag/AU.gif)\n```\n\n**Reference style:**\n\n```markdown\n![][flag_AU]\n\n[flag_AU]: /wiki/shared/flag/AU.gif\n```\n\nImages should use the inline linking style. References to reference links must be placed at the bottom of the article.\n\nImages must be placed in a folder named `img`, located in the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared/` folder.\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive \"GitHub\") to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\n### Formatting and positioning\n\n*Note: It is currently not possible to float an image or have text wrap around it.*\n\nImages on the website will be centred when it is on a single line, by themself. Otherwise, they will be positioned inline with the paragraph. The following example will place the image in the center:\n\n```markdown\nInstalling osu! is easy. First, download the installer from the download page.\n\n![](img/download-page.jpg)\n\nThen locate the installer and run it.\n```\n\n### Alt text\n\nImages should have alt text unless it is for decorative purposes.\n\n### Captions\n\nImages are given captions on the website if they fulfill these conditions:\n\n1. The image is by itself.\n2. The image is not inside a heading.\n3. The image has title text.\n\nCaptions are assumed via the title text, which must be in plain text. Images with captions are also centred with the image on the website.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\nFlag icons next to user links must be separate from the link text. See the following example:\n\n```markdown\n![][flag_AU] [peppy](https://osu.ppy.sh/users/2)\n```\n\n### Flag icons\n\n*For a list of flag icons, see: [issue \\#328](https://github.com/ppy/osu-wiki/issues/328 \"GitHub\")*\n\nThe flag icons use the two letter code (in all capital letters) and end with `.gif`. When adding a flag inline, use this format:\n\n```markdown\n![](/wiki/shared/flag/xx.gif)\n```\n\nWhere `xx` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 \"Wikipedia\") two-lettered country code for the flag.\n\nThe full country name should be added in the title text. The country code in the alternate text is optional, but must be applied to all flag icons in the article.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ryūta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Blockquotes\n\nThe blockquote is limited to quoting text from someone. It must not be used to format text otherwise.\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" + "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\\\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119) to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | \u0627\u064e\u0644\u0652\u0639\u064e\u0631\u064e\u0628\u0650\u064a\u064e\u0651\u0629\u064f |\n| `be.md` | Belarusian | \u0411\u0435\u043b\u0430\u0440\u0443\u0441\u043a\u0430\u044f \u043c\u043e\u0432\u0430 |\n| `bg.md` | Bulgarian | \u0411\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 |\n| `cs.md` | Czech | \u010cesky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `el.md` | Greek | \u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac |\n| `es.md` | Spanish | Espa\u00f1ol |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Fran\u00e7ais |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | \u65e5\u672c\u8a9e |\n| `ko.md` | Korean | \ud55c\uad6d\uc5b4 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Portugu\u00eas |\n| `pt-br.md` | Brazilian Portuguese | Portugu\u00eas (Brasil) |\n| `ro.md` | Romanian | Rom\u00e2n\u0103 |\n| `ru.md` | Russian | \u0420\u0443\u0441\u0441\u043a\u0438\u0439 |\n| `sk.md` | Slovak | Sloven\u010dina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | \u0e44\u0e17\u0e22 |\n| `tr.md` | Turkish | T\u00fcrk\u00e7e |\n| `uk.md` | Ukrainian | \u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430 \u043c\u043e\u0432\u0430 |\n| `vi.md` | Vietnamese | Ti\u1ebfng Vi\u1ec7t |\n| `zh.md` | Chinese (Simplified) | \u7b80\u4f53\u4e2d\u6587 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | \u7e41\u9ad4\u4e2d\u6587\uff08\u53f0\u7063\uff09 |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example) and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nEnglish articles may become outdated when the content they contain is misleading or no longer relevant. These should receive an `outdated` tag, which must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Outdated translations\n\nTranslated articles that are outdated must use the `outdated_translation` tag when the English variant is updated, except for minor wording, grammar changes, and the like, that do not affect the meaning of the article.\n\n```yaml\noutdated_translation: true\n```\n\nWhen outdating translations, they must also receive an `outdated_since` tag that points to the first commit where the English version is updated.\n\n```yaml\noutdated_since: 29eac89cd535f8b071ca000af8fe4f0be22bdc9b\n```\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals)) for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}*\n\n*For {description}, see: {article} and {article}*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnote), notices must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Stacked hatnotes and notices\n\nMultiple hatnotes and notices may be stacked when necessary. When doing this, they must be stacked without blank lines and use trailing backslashes:\n\n```markdown\n*Warning: {warning}.*\\\n*See also: {article}*\n```\n\nIn many cases, it may be more fitting to embed extraneous hatnotes or notices into paragraph text instead of stacking many of them.\n\n## Emphasising\n\n### Bolding\n\nBold text must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\nNames of work or video games should be italicised. osu! \u2014 the game \u2014 is exempt from this.\n\nAs an example, when referring to songs in the format of `{artist} - {title}`, the whole part is a reference to the work and should therefore be italicised:\n\n```markdown\n*cYsmix - triangles* is a one of the three intro songs that can be heard when starting the game client.\n```\n\nArtist names are otherwise generally not italicised. This means that in free-form references, only the title should be italicised, because the artist name is then not part of the name of the work:\n\n```markdown\n*Blue Zenith* by xi is an infamous song in the osu! community due to a famous score set by a top player on a certain beatmap.\n```\n\n### Emphasis and links\n\nLinked text appears in a different colour which already provides emphasis, and therefore does not need further emphasis:\n\n```markdown\n[Camellia - OOPARTS](https://cametek.bandcamp.com/track/parts) is an example of a song officially created specifically for osu!, otherwise known as an *osu! original*, since it was specifically commissioned for the osu! World Cup 2020 tiebreaker.\n```\n\nThis however does not apply if the referenced work is not the only part of the link:\n\n```markdown\n[Voltaeyx's beatmap of *TheFatRat - Mayday (feat. Laura Brehm)*](https://osu.ppy.sh/beatmapsets/756794) amassed considerable popularity in 2018 due to its unique overlapping slider patterns.\n```\n\nThe above type of construction should be used sparingly, and must not be used in places with many links, such as tables or lists.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings) and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\\\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents.*\n\n### Custom identifiers\n\nIt is possible to redefine a section's identifier, which is used for linking to it directly. Custom identifiers should be used in case the automatically generated ones are too long or contain tricky punctuation marks or images:\n\n\n\n```markdown\n## My cooldown has passed. How do I appeal? {#appeal}\n\n## Common restriction reasons and cooldowns {#common-reasons}\n\n## Ideas for a multiplayer match {id=\u0438\u0434\u0435\u0438-\u0434\u043b\u044f-\u043c\u0443\u043b\u044c\u0442\u0438\u043f\u043b\u0435\u0435\u0440\u0430} \n```\n\nThis feature can also be used for tagging a specific part of the article which doesn't have a heading. Use it sparingly:\n\n```markdown\n> That's it! You're well on your way to becoming an osu! rhythm champion!\n{#tutorial-quote}\n```\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\n*See also: [Footnotes](#footnotes)*\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game modifier](/wiki/Game_modifier)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game modifier][game mods link]\n\n[game mods link]: /wiki/Game_modifier\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/People/The_Team/Developers)\n[Developers](/wiki/People/The_Team/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Client/Beatmap_editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`\u00a7`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria \u00a7 Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficulty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory)*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![Gold crown](/wiki/shared/crown-gold.png \"1st place\")\n```\n\n**Reference style:**\n\n```markdown\n![Gold crown][GCrown]\n\n[GCrown]: /wiki/shared/crown-gold.png \"1st place\"\n```\n\nImages should use the inline linking style. Reference link definitions must be placed at the bottom of the article.\n\nAll block images on the page (that have nothing else on the same line) are combined into a single gallery, which can be navigated using arrow icons on both sides of the screen, keyboard shortcuts, or screen swipes on mobile devices.\n\n### Alternative and title text\n\nThe text in the first pair of square brackets (*alternative text*) should describe the image literally. It is used by screen readers or when the image fails to load. It can be omitted if it is identical to the title text or if the image is included only for decorative purposes.\n\nThe text in the quotation marks (*title text*) should give additional context to the image or indicate its meaning. It is displayed as a tooltip when hovering over the image and used as a caption if applicable. It does not support any markdown formatting.\n\n### Display\n\nIf an image is the sole content of a paragraph, it displays as a centred block. Otherwise, it flows with the surrounding inline text.\n\nBlock images with title text display the title text as a caption below the image. Avoid adding [HTML comment](#comments) or any other text on the same line as the image, as this will cause the caption not to be rendered.\n\nBlock images are commonly paired with [infobox](#infoboxes) formatting to reduce their initial size and float them to the side of other content:\n\n```markdown\n::: Infobox\n![](img/mod-response.png \"An example of a response to a mod\")\n:::\n```\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive) to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\nImages must be placed in a folder named `img` under the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared` folder.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\n## Flag icons\n\nThe flag icons use the two letter code (in all capital letters) to match a certain territory. When adding a flag inline, use this format:\n\n```markdown\n::{ flag=XX }::\n```\n\nWhere `XX` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) two-lettered country code for the flag.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ry\u016bta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Infoboxes\n\nAn infobox is a fixed-width block which is aligned to the right side of the article. It may contain a relevant image, which explains the surrounding text, or a block of navigation that links to other articles from the same category.\n\nExample use, rendered on the right:\n\n\n\n::: Infobox\n![](/wiki/shared/mods/SD.png \"Sudden Death mod icon\")\n:::\n\n```markdown\n::: Infobox\n![](/wiki/shared/mods/SD.png \"Sudden Death mod icon\")\n:::\n```\n\nInfoboxes should be used with caution in the following cases:\n\n- Short sections: the next section's heading appears below any infoboxes, leaving a large gap after the text.\n- Several images at once: instead, use individual infoboxes for better design.\n\n## Footnotes\n\nFootnotes are short notes located at the end of the page. They are used for citing sources, or providing background information that would otherwise disrupt the flow of the article. Footnotes may contain text formatting and links.\n\nIn the osu! wiki, footnotes are implemented using special syntax (`[^identifier]`). Footnotes can use any identifier, but they will automatically be rendered as superscripts with increasing numbers in order of their first appearance. Translations must not modify identifiers of footnotes.\n\nFootnote references are placed directly after the words, phrases, or sentences they explain, with no space in between. These references must be placed after punctuation, except for parentheses, when they pertain to the contents inside, and dashes.\n\nThe footnotes themselves must be placed in a separate second-level heading at the end of the article. Depending on the content, the heading used may be `References`, `Notes`, or `Notes and references`.\n\nCorrect usage examples:\n\n```markdown\nThe osu! wiki is a project that was meant to replace the old FAQ system.[^wiki-faq] It was named after the rhythm game osu![^osu] and the largest open online encyclopedia, Wikipedia. From the very start, it had attracted skillful translators[^wiki-tl] and editors.\n\n## References\n\n[^wiki-faq]: https://osu.ppy.sh/community/forums/topics/68525\n[^wiki-tl]: https://osu.ppy.sh/community/forums/posts/1177500\n[^osu]: https://osu.ppy.sh/community/forums/posts/1178153\n```\n\n### Citations\n\nCitations, or references, are used to identify a source of information. Citations via footnotes should be preferred over inline links.\n\nReferences should whenever applicable specify author, date, service/platform, and title. The exact format may vary depending on the referenced material with a preference for brevity.\n\nExamples:\n\n```markdown\nThe first version of the osu!api was made available on July 2, 2013.[^api-first-usage] It had received critical acclaim from users.[^api-praise] A new version of API, released several years later, contains many more capabilities.[^api-v2-2020] Endpoint versioning is common among web APIs.[^web-api]\n\n## References\n\n[^api-first-usage]: [Forum thread by peppy (2013-07-02) \"osu!api open beta\"](https://osu.ppy.sh/community/forums/posts/2403913)\n[^api-praise]: [Forum post by Menchi (2013-11-02) in \"osu!api open beta\"](https://osu.ppy.sh/community/forums/posts/2662247)\n[^api-v2-2020]: [Tweet by @ppy (2020-03-20)](https://twitter.com/ppy/status/1263083636363948032)\n[^web-api]: [\"Web API\" on Wikipedia](https://en.wikipedia.org/wiki/Web_API)\n```\n\n### Notes\n\nFootnotes may be used for storing explanations or tangential remarks which cannot be inlined without worsening the article's readability, or are less significant than the article itself. Such footnotes may use free-form text.\n\nExample:\n\n```markdown\nA tournament must not be organised and run by an inexperienced team of unaccomplished and irreputable staff.[^staff]\n\n## Notes\n\n[^staff]: An *inexperienced* staff member is loosely defined as someone who has been playing osu! for less than an hour in total.\n```\n\n## Blockquotes\n\nThe blockquote is limited to [quoting someone or something](/wiki/Article_styling_criteria/Writing#block-quotation). It must not be used to format text otherwise.\n\n```markdown\n> plz enjoy game\n\n\u2014rrtyui\n```\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" + }; + + // From https://osu.ppy.sh/api/v2/wiki/en/Article_styling_criteria + private APIWikiPage responseArticleParentPage => new APIWikiPage + { + Title = "Article styling criteria", + Layout = "markdown_page", + Path = "Article_styling_criteria", + Locale = "en", + Subtitle = null, + Markdown = + "---\ntags:\n - wiki standards\n---\n\n# Article styling criteria\n\n*For news posts, see: [News Styling Criteria](/wiki/News_styling_criteria)*\n\nThe article styling criteria (ASC) serve as the osu! wiki's enforced styling standards to keep consistency in clarity, formatting, and layout in all articles, and to help them strive for proper grammar, correct spelling, and correct information.\n\nThese articles are primarily tools to aid in reviewing and represent the consensus of osu! wiki contributors formed over the years. Since the wiki is a collaborative effort through the review process, it is not necessary to read or memorise all of the ASC at once. If you are looking to contribute, read the [contribution guide](/wiki/osu!_wiki/Contribution_guide).\n\nTo suggest changes regarding the article styling criteria, [open an issue on GitHub](https://github.com/ppy/osu-wiki/issues/new).\n\n## Standards\n\n*Notice: The articles below use [RFC 2119](https://tools.ietf.org/html/rfc2119) to describe requirement levels.*\n\nThe article styling criteria are split up into two articles:\n\n- [Formatting](Formatting): includes Markdown and other formatting rules\n- [Writing](Writing): includes writing practices and other grammar rules\n" }; } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index e6882081dd..c71bdb3a06 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -25,17 +25,21 @@ namespace osu.Game.Tests.Visual.Playlists protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room(); + base.SetUpSteps(); - Child = settings = new TestRoomSettings(SelectedRoom.Value) + AddStep("create overlay", () => { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible } - }; - }); + SelectedRoom.Value = new Room(); + + Child = settings = new TestRoomSettings(SelectedRoom.Value) + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible } + }; + }); + } [Test] public void TestButtonEnabledOnlyWithNameAndBeatmap() diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index 5961ed74ad..9a0dda056a 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -15,21 +15,25 @@ namespace osu.Game.Tests.Visual.Playlists { public class TestScenePlaylistsParticipantsList : OnlinePlayTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room { RoomID = { Value = 7 } }; + base.SetUpSteps(); - for (int i = 0; i < 50; i++) + AddStep("create list", () => { - SelectedRoom.Value.RecentParticipants.Add(new APIUser + SelectedRoom.Value = new Room { RoomID = { Value = 7 } }; + + for (int i = 0; i < 50; i++) { - Username = "peppy", - Statistics = new UserStatistics { GlobalRank = 1234 }, - Id = 2 - }); - } - }); + SelectedRoom.Value.RecentParticipants.Add(new APIUser + { + Username = "peppy", + Statistics = new UserStatistics { GlobalRank = 1234 }, + Id = 2 + }); + } + }); + } [Test] public void TestHorizontalLayout() diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 8a04cd96fe..26fa740159 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -61,6 +61,7 @@ namespace osu.Game.Tests.Visual.Playlists userScore = TestResources.CreateTestScoreInfo(); userScore.TotalScore = 0; userScore.Statistics = new Dictionary(); + userScore.MaximumStatistics = new Dictionary(); bindHandler(); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index c0cd2d9157..b304b34275 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -16,9 +17,12 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Playlists; @@ -32,7 +36,6 @@ namespace osu.Game.Tests.Visual.Playlists public class TestScenePlaylistsRoomCreation : OnlinePlayTestScene { private BeatmapManager manager; - private RulesetStore rulesets; private TestPlaylistsRoomSubScreen match; @@ -41,8 +44,8 @@ namespace osu.Game.Tests.Visual.Playlists [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -222,10 +225,26 @@ namespace osu.Game.Tests.Visual.Playlists public new Bindable Beatmap => base.Beatmap; + [Resolved(canBeNull: true)] + [CanBeNull] + private IDialogOverlay dialogOverlay { get; set; } + public TestPlaylistsRoomSubScreen(Room room) : base(room) { } + + public override bool OnExiting(ScreenExitEvent e) + { + // For testing purposes allow the screen to exit without confirming on second attempt. + if (!ExitConfirmed && dialogOverlay?.CurrentDialog is ConfirmDiscardChangesDialog confirmDialog) + { + confirmDialog.PerformAction(); + return true; + } + + return base.OnExiting(e); + } } } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 44cb438a6b..e014d79402 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -1,12 +1,11 @@ // 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.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -20,15 +19,25 @@ namespace osu.Game.Tests.Visual.Ranking { public class TestSceneHitEventTimingDistributionGraph : OsuTestScene { - private HitEventTimingDistributionGraph graph; + private HitEventTimingDistributionGraph graph = null!; + private readonly BindableFloat width = new BindableFloat(600); + private readonly BindableFloat height = new BindableFloat(130); private static readonly HitObject placeholder_object = new HitCircle(); + public TestSceneHitEventTimingDistributionGraph() + { + width.BindValueChanged(e => graph.Width = e.NewValue); + height.BindValueChanged(e => graph.Height = e.NewValue); + } + [Test] public void TestManyDistributedEvents() { createTest(CreateDistributedHitEvents()); AddStep("add adjustment", () => graph.UpdateOffset(10)); + AddSliderStep("width", 0.0f, 1000.0f, width.Value, width.Set); + AddSliderStep("height", 0.0f, 1000.0f, height.Value, height.Set); } [Test] @@ -43,6 +52,65 @@ namespace osu.Game.Tests.Visual.Ranking createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList()); } + [Test] + public void TestSparse() + { + createTest(new List + { + new HitEvent(-7, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(-6, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(-5, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(5, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(6, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(7, HitResult.Perfect, placeholder_object, placeholder_object, null), + }); + } + + [Test] + public void TestVariousTypesOfHitResult() + { + createTest(CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 36 ? HitResult.Miss + : offset > 32 ? HitResult.Meh + : offset > 24 ? HitResult.Ok + : offset > 16 ? HitResult.Good + : offset > 8 ? HitResult.Great + : HitResult.Perfect; + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }).ToList()); + } + + [Test] + public void TestMultipleWindowsOfHitResult() + { + var wide = CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 36 ? HitResult.Miss + : offset > 32 ? HitResult.Meh + : offset > 24 ? HitResult.Ok + : offset > 16 ? HitResult.Good + : offset > 8 ? HitResult.Great + : HitResult.Perfect; + + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }); + var narrow = CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 25 ? HitResult.Miss + : offset > 20 ? HitResult.Meh + : offset > 15 ? HitResult.Ok + : offset > 10 ? HitResult.Good + : offset > 5 ? HitResult.Great + : HitResult.Perfect; + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }); + createTest(wide.Concat(narrow).ToList()); + } + [Test] public void TestZeroTimeOffset() { @@ -80,7 +148,7 @@ namespace osu.Game.Tests.Visual.Ranking { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(600, 130) + Size = new Vector2(width.Value, height.Value) } }; }); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index b3d1966511..6ff53663ba 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -29,11 +29,7 @@ namespace osu.Game.Tests.Visual.Settings { Child = textBox = new SettingsTextBox { - Current = new Bindable - { - Default = "test", - Value = "test" - } + Current = new Bindable("test") }; }); AddUntilStep("wait for loaded", () => textBox.IsLoaded); @@ -59,11 +55,7 @@ namespace osu.Game.Tests.Visual.Settings { Child = textBox = new SettingsTextBox { - Current = new Bindable - { - Default = "test", - Value = "test" - } + Current = new Bindable("test") }; }); AddUntilStep("wait for loaded", () => textBox.IsLoaded); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs index 98de712703..3cf6f7febf 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 { @@ -63,11 +67,7 @@ namespace osu.Game.Tests.Visual.Settings }; [SettingSource("Sample number textbox", "Textbox number entry", SettingControlType = typeof(SettingsNumberBox))] - public Bindable IntTextBoxBindable { get; } = new Bindable - { - Default = null, - Value = null - }; + public Bindable IntTextBoxBindable { get; } = new Bindable(); } private enum TestEnum @@ -75,5 +75,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/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index 4510fda11d..1042341337 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -126,7 +126,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select unchanged Difficulty Adjust mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - var difficultyAdjustMod = ruleset.CreateMod(); + var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.Difficulty); SelectedMods.Value = new[] { difficultyAdjustMod }; }); @@ -145,7 +145,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select changed Difficulty Adjust mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - var difficultyAdjustMod = ruleset.CreateMod(); + var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); var originalDifficulty = advancedStats.BeatmapInfo.Difficulty; difficultyAdjustMod.ReadFromDifficulty(originalDifficulty); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 66b9fa990a..0e72463d1e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -173,7 +173,7 @@ namespace osu.Game.Tests.Visual.SongSelect if (isIterating) AddUntilStep("selection changed", () => !carousel.SelectedBeatmapInfo?.Equals(selection) == true); else - AddUntilStep("selection not changed", () => carousel.SelectedBeatmapInfo.Equals(selection)); + AddUntilStep("selection not changed", () => carousel.SelectedBeatmapInfo?.Equals(selection) == true); } } } @@ -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); @@ -374,7 +382,7 @@ namespace osu.Game.Tests.Visual.SongSelect // buffer the selection setSelected(3, 2); - AddStep("get search text", () => searchText = carousel.SelectedBeatmapSet.Metadata.Title); + AddStep("get search text", () => searchText = carousel.SelectedBeatmapSet!.Metadata.Title); setSelected(1, 3); @@ -486,9 +494,43 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Something is selected", () => carousel.SelectedBeatmapInfo != null); } - /// - /// Test sorting - /// + [Test] + public void TestSortingDateSubmitted() + { + var sets = new List(); + const string zzz_string = "zzzzz"; + + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 20; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(5); + + if (i >= 2 && i < 10) + set.DateSubmitted = DateTimeOffset.Now.AddMinutes(i); + if (i < 5) + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); + + sets.Add(set); + } + }); + + loadBeatmaps(sets); + + AddStep("Sort by date submitted", () => carousel.Filter(new FilterCriteria { Sort = SortMode.DateSubmitted }, false)); + checkVisibleItemCount(diff: false, count: 8); + checkVisibleItemCount(diff: true, count: 5); + AddStep("Sort by date submitted and string", () => carousel.Filter(new FilterCriteria + { + Sort = SortMode.DateSubmitted, + SearchText = zzz_string + }, false)); + checkVisibleItemCount(diff: false, count: 3); + checkVisibleItemCount(diff: true, count: 5); + } + [Test] public void TestSorting() { @@ -496,18 +538,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); @@ -517,12 +564,82 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert($"Check {zzz_string} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Artist == zzz_string); } + /// + /// Ensures stability is maintained on different sort modes for items with equal properties. + /// [Test] public void TestSortingStability() { var sets = new List(); + int idOffset = 0; - for (int i = 0; i < 20; i++) + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 10; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = $"artist {i / 2}"; + beatmap.Metadata.Title = $"title {9 - i}"; + + 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)); + + AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddAssert("Items are in reverse order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + sets.Count - index - 1).All(b => b)); + + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddAssert("Items reset to original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b)); + } + + /// + /// Ensures stability is maintained on different sort modes while a new item is added to the carousel. + /// + [Test] + public void TestSortingStabilityWithNewItems() + { + List sets = new List(); + int idOffset = 0; + + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(3); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; + + sets.Add(set); + } + + idOffset = sets.First().OnlineID; + }); + + loadBeatmaps(sets); + + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + assertOriginalOrderMaintained(); + + AddStep("Add new item", () => { var set = TestResources.CreateTestBeatmapSetInfo(); @@ -532,18 +649,19 @@ namespace osu.Game.Tests.Visual.SongSelect beatmap.Metadata.Artist = "same artist"; beatmap.Metadata.Title = "same title"; - sets.Add(set); - } + carousel.UpdateBeatmapSet(set); + }); - int 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 == index + idOffset).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 == index + idOffset).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] @@ -551,13 +669,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); @@ -578,7 +701,7 @@ namespace osu.Game.Tests.Visual.SongSelect setSelected(2, 1); AddAssert("Selection is non-null", () => currentSelection != null); - AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet)); + AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet!)); waitForSelection(2); AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); @@ -681,7 +804,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("filter to ruleset 0", () => carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); - AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 0); + AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 0); AddStep("remove mixed set", () => { @@ -709,8 +832,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); @@ -726,7 +854,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Restore no filter", () => { carousel.Filter(new FilterCriteria(), false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); }); } @@ -741,6 +869,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[] @@ -769,10 +899,10 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Restore different ruleset filter", () => { carousel.Filter(new FilterCriteria { Ruleset = rulesets.GetRuleset(1) }, false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); }); - AddAssert("selection changed", () => !carousel.SelectedBeatmapInfo.Equals(manySets.First().Beatmaps.First())); + AddAssert("selection changed", () => !carousel.SelectedBeatmapInfo!.Equals(manySets.First().Beatmaps.First())); } AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); @@ -825,7 +955,8 @@ namespace osu.Game.Tests.Visual.SongSelect checkVisibleItemCount(true, 15); } - private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, bool randomDifficulties = false) + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, + bool randomDifficulties = false) { bool changed = false; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 1b9b59676b..1839821bb5 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.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; using System.Collections.Generic; using System.Linq; @@ -36,18 +34,17 @@ namespace osu.Game.Tests.Visual.SongSelect [Cached(typeof(IDialogOverlay))] private readonly DialogOverlay dialogOverlay; - private ScoreManager scoreManager; - - private RulesetStore rulesetStore; - private BeatmapManager beatmapManager; + private ScoreManager scoreManager = null!; + private RulesetStore rulesetStore = null!; + private BeatmapManager beatmapManager = null!; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); - dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, Scheduler)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); return dependencies; @@ -74,7 +71,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestLocalScoresDisplay() { - BeatmapInfo beatmapInfo = null; + BeatmapInfo beatmapInfo = null!; AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); @@ -140,11 +137,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, }); } @@ -164,12 +157,8 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, - }, + CountryCode = CountryCode.ES, + } }); } @@ -225,11 +214,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, }, new ScoreInfo @@ -246,11 +231,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 4608074, Username = @"Skycries", - Country = new Country - { - FullName = @"Brazil", - FlagName = @"BR", - }, + CountryCode = CountryCode.BR, }, }, new ScoreInfo @@ -268,11 +249,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 1014222, Username = @"eLy", - Country = new Country - { - FullName = @"Japan", - FlagName = @"JP", - }, + CountryCode = CountryCode.JP, }, }, new ScoreInfo @@ -290,11 +267,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 1541390, Username = @"Toukai", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, }, new ScoreInfo @@ -312,11 +285,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 2243452, Username = @"Satoruu", - Country = new Country - { - FullName = @"Venezuela", - FlagName = @"VE", - }, + CountryCode = CountryCode.VE, }, }, new ScoreInfo @@ -334,11 +303,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 2705430, Username = @"Mooha", - Country = new Country - { - FullName = @"France", - FlagName = @"FR", - }, + CountryCode = CountryCode.FR, }, }, new ScoreInfo @@ -356,11 +321,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 7151382, Username = @"Mayuri Hana", - Country = new Country - { - FullName = @"Thailand", - FlagName = @"TH", - }, + CountryCode = CountryCode.TH, }, }, new ScoreInfo @@ -378,11 +339,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 2051389, Username = @"FunOrange", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, }, new ScoreInfo @@ -400,11 +357,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6169483, Username = @"-Hebel-", - Country = new Country - { - FullName = @"Mexico", - FlagName = @"MX", - }, + CountryCode = CountryCode.MX, }, }, new ScoreInfo @@ -422,11 +375,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6702666, Username = @"prhtnsm", - Country = new Country - { - FullName = @"Germany", - FlagName = @"DE", - }, + CountryCode = CountryCode.DE, }, }, }; @@ -435,7 +384,7 @@ namespace osu.Game.Tests.Visual.SongSelect private class FailableLeaderboard : BeatmapLeaderboard { public new void SetErrorState(LeaderboardState state) => base.SetErrorState(state); - public new void SetScores(IEnumerable scores, ScoreInfo userScore = default) => base.SetScores(scores, userScore); + public new void SetScores(IEnumerable? scores, ScoreInfo? userScore = null) => base.SetScores(scores, userScore); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs index 27b485156c..c42b51c1a6 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs @@ -98,7 +98,7 @@ namespace osu.Game.Tests.Visual.SongSelect OsuLogo logo = new OsuLogo { Scale = new Vector2(0.15f) }; - Remove(testDifficultyCache); + Remove(testDifficultyCache, false); Children = new Drawable[] { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index 6807180640..dadcd43db5 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -1,8 +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 - +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -21,6 +20,7 @@ using osu.Game.Rulesets; using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osuTK.Input; +using Realms; namespace osu.Game.Tests.Visual.SongSelect { @@ -28,35 +28,28 @@ namespace osu.Game.Tests.Visual.SongSelect { protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - private CollectionManager collectionManager; - - private RulesetStore rulesets; - private BeatmapManager beatmapManager; - - private FilterControl control; + private BeatmapManager beatmapManager = null!; + private FilterControl control = null!; [BackgroundDependencyLoader] private void load(GameHost host) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesets, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); base.Content.AddRange(new Drawable[] { - collectionManager = new CollectionManager(LocalStorage), Content }); - - Dependencies.Cache(collectionManager); } [SetUp] public void SetUp() => Schedule(() => { - collectionManager.Collections.Clear(); + writeAndRefresh(r => r.RemoveAll()); Child = control = new FilterControl { @@ -77,8 +70,8 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestCollectionAddedToDropdown() { - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); assertCollectionDropdownContains("1"); assertCollectionDropdownContains("2"); } @@ -86,9 +79,11 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestCollectionRemovedFromDropdown() { - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); - AddStep("remove collection", () => collectionManager.Collections.RemoveAt(0)); + BeatmapCollection first = null!; + + AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1")))); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); + AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first))); assertCollectionDropdownContains("1", false); assertCollectionDropdownContains("2"); @@ -97,16 +92,17 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestCollectionRenamed() { - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); AddStep("select collection", () => { - var dropdown = control.ChildrenOfType().Single(); + var dropdown = control.ChildrenOfType().Single(); dropdown.Current.Value = dropdown.ItemSource.ElementAt(1); }); addExpandHeaderStep(); - AddStep("change name", () => collectionManager.Collections[0].Name.Value = "First"); + AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First")); assertCollectionDropdownContains("First"); assertCollectionHeaderDisplays("First"); @@ -124,7 +120,8 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestCollectionFilterHasAddButton() { addExpandHeaderStep(); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1))); AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent); } @@ -134,7 +131,8 @@ namespace osu.Game.Tests.Visual.SongSelect { addExpandHeaderStep(); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value); @@ -150,14 +148,16 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); - AddStep("add beatmap to collection", () => collectionManager.Collections[0].BeatmapHashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)); - AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); - AddStep("remove beatmap from collection", () => collectionManager.Collections[0].BeatmapHashes.Clear()); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash))); + assertFirstButtonIs(FontAwesome.Solid.MinusSquare); + + AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear())); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); } [Test] @@ -167,24 +167,29 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); addClickAddOrRemoveButtonStep(1); - AddAssert("collection contains beatmap", () => collectionManager.Collections[0].BeatmapHashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); - AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); + assertFirstButtonIs(FontAwesome.Solid.MinusSquare); addClickAddOrRemoveButtonStep(1); - AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].BeatmapHashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); } [Test] public void TestManageCollectionsFilterIsNotSelected() { + bool received = false; + addExpandHeaderStep(); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List { "abc" })))); + assertCollectionDropdownContains("1"); + AddStep("select collection", () => { InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1)); @@ -193,21 +198,39 @@ namespace osu.Game.Tests.Visual.SongSelect addExpandHeaderStep(); + AddStep("watch for filter requests", () => + { + received = false; + control.ChildrenOfType().First().RequestFilter = () => received = true; + }); + AddStep("click manage collections filter", () => { InputManager.MoveMouseTo(getCollectionDropdownItems().Last()); InputManager.Click(MouseButton.Left); }); - AddAssert("collection filter still selected", () => control.CreateCriteria().Collection?.Name.Value == "1"); + AddAssert("collection filter still selected", () => control.CreateCriteria().CollectionBeatmapMD5Hashes.Any()); + + AddAssert("filter request not fired", () => !received); } + private void writeAndRefresh(Action action) => Realm.Write(r => + { + action(r); + r.Refresh(); + }); + + private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All().First()); + private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true) - => AddAssert($"collection dropdown header displays '{collectionName}'", - () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName)); + => AddUntilStep($"collection dropdown header displays '{collectionName}'", + () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName)); + + private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon)); private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) => - AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", + AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); @@ -216,7 +239,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void addExpandHeaderStep() => AddStep("expand header", () => { - InputManager.MoveMouseTo(control.ChildrenOfType().Single()); + InputManager.MoveMouseTo(control.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); @@ -227,6 +250,6 @@ namespace osu.Game.Tests.Visual.SongSelect }); private IEnumerable.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems() - => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); + => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 159a3b1923..cc8746959b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -1,19 +1,23 @@ // 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 System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; @@ -23,6 +27,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -41,11 +46,12 @@ namespace osu.Game.Tests.Visual.SongSelect [TestFixture] public class TestScenePlaySongSelect : ScreenTestScene { - private BeatmapManager manager; - private RulesetStore rulesets; - private MusicController music; - private WorkingBeatmap defaultBeatmap; - private TestSongSelect songSelect; + private BeatmapManager manager = null!; + private RulesetStore rulesets = null!; + private MusicController music = null!; + private WorkingBeatmap defaultBeatmap = null!; + private OsuConfigManager config = null!; + private TestSongSelect? songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -54,7 +60,7 @@ namespace osu.Game.Tests.Visual.SongSelect // At a point we have isolated interactive test runs enough, this can likely be removed. Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(Realm); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); Dependencies.Cache(music = new MusicController()); @@ -64,8 +70,6 @@ namespace osu.Game.Tests.Visual.SongSelect Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } - private OsuConfigManager config; - public override void SetUpSteps() { base.SetUpSteps(); @@ -80,7 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect songSelect = null; }); - AddStep("delete all beatmaps", () => manager?.Delete()); + AddStep("delete all beatmaps", () => manager.Delete()); } [Test] @@ -93,7 +97,7 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); AddUntilStep("wait for placeholder hidden", () => getPlaceholder()?.State.Value == Visibility.Hidden); - AddStep("delete all beatmaps", () => manager?.Delete()); + AddStep("delete all beatmaps", () => manager.Delete()); AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); } @@ -139,7 +143,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + AddAssert("filter count is 1", () => songSelect?.FilterCount == 1); } [Test] @@ -151,7 +155,7 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); @@ -161,7 +165,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -174,7 +178,7 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); @@ -184,7 +188,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Down); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -197,23 +201,23 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); - AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any()); + AddUntilStep("wait for beatmaps to load", () => songSelect!.Carousel.ChildrenOfType().Any()); AddStep("select next and enter", () => { - InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect.Carousel.SelectedBeatmapInfo))); + InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() + .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); InputManager.Click(MouseButton.Left); InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -226,14 +230,14 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); AddStep("select next and enter", () => { - InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect.Carousel.SelectedBeatmapInfo))); + InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() + .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); InputManager.PressButton(MouseButton.Left); @@ -242,7 +246,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.ReleaseButton(MouseButton.Left); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -255,11 +259,11 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("filter count is 1", () => songSelect!.FilterCount == 1); } [Test] @@ -273,13 +277,35 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("filter count is 2", () => songSelect.FilterCount == 2); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("filter count is 2", () => songSelect!.FilterCount == 2); + } + + [Test] + public void TestCarouselSelectionUpdatesOnResume() + { + addRulesetImportStep(0); + + createSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + + AddStep("update beatmap", () => + { + var selectedBeatmap = Beatmap.Value.BeatmapInfo; + var anotherBeatmap = Beatmap.Value.BeatmapSetInfo.Beatmaps.Except(selectedBeatmap.Yield()).First(); + Beatmap.Value = manager.GetWorkingBeatmap(anotherBeatmap); + }); + + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("carousel updated", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(Beatmap.Value.BeatmapInfo) == true); } [Test] @@ -291,15 +317,15 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); checkMusicPlaying(true); - AddStep("select first", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.First())); + AddStep("select first", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.First())); checkMusicPlaying(true); AddStep("manual pause", () => music.TogglePause()); checkMusicPlaying(false); - AddStep("select next difficulty", () => songSelect.Carousel.SelectNext(skipDifficulties: false)); + AddStep("select next difficulty", () => songSelect!.Carousel.SelectNext(skipDifficulties: false)); checkMusicPlaying(false); - AddStep("select next set", () => songSelect.Carousel.SelectNext()); + AddStep("select next set", () => songSelect!.Carousel.SelectNext()); checkMusicPlaying(true); } @@ -339,13 +365,13 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestDummy() { createSongSelect(); - AddUntilStep("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap); + AddUntilStep("dummy selected", () => songSelect!.CurrentBeatmap == defaultBeatmap); - AddUntilStep("dummy shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap == defaultBeatmap); + AddUntilStep("dummy shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap == defaultBeatmap); addManyTestMaps(); - AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); } [Test] @@ -354,7 +380,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); addManyTestMaps(); - AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); @@ -371,7 +397,7 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addRulesetImportStep(2); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -381,13 +407,73 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); addRulesetImportStep(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); changeRuleset(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 1); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 1); changeRuleset(0); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); + } + + [Test] + public void TestSelectionRetainedOnBeatmapUpdate() + { + createSongSelect(); + changeRuleset(0); + + Live? original = null; + int originalOnlineSetID = 0; + + AddStep(@"Sort by artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + + AddStep("import original", () => + { + original = manager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely(); + + Debug.Assert(original != null); + + originalOnlineSetID = original.Value.OnlineID; + }); + + // This will move the beatmap set to a different location in the carousel. + AddStep("Update original with bogus info", () => + { + Debug.Assert(original != null); + + original.PerformWrite(set => + { + foreach (var beatmap in set.Beatmaps) + { + beatmap.Metadata.Artist = "ZZZZZ"; + beatmap.OnlineID = 12804; + } + }); + }); + + AddRepeatStep("import other beatmaps", () => + { + var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(); + + foreach (var beatmap in testBeatmapSetInfo.Beatmaps) + beatmap.Metadata.Artist = ((char)RNG.Next('A', 'Z')).ToString(); + + manager.Import(testBeatmapSetInfo); + }, 10); + + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); + + Task?> updateTask = null!; + + AddStep("update beatmap", () => + { + Debug.Assert(original != null); + + updateTask = manager.ImportAsUpdate(new ProgressNotification(), new ImportTask(TestResources.GetQuickTestBeatmapForImport()), original.Value); + }); + AddUntilStep("wait for update completion", () => updateTask.IsCompleted); + + AddUntilStep("retained selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); } [Test] @@ -397,13 +483,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); addRulesetImportStep(0); addRulesetImportStep(0); addRulesetImportStep(0); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap/ruleset externally", () => { @@ -414,10 +500,10 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); + AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -427,13 +513,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); addRulesetImportStep(0); addRulesetImportStep(0); addRulesetImportStep(0); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap/ruleset externally", () => { @@ -444,12 +530,12 @@ namespace osu.Game.Tests.Visual.SongSelect Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 0); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); AddUntilStep("has correct ruleset", () => Ruleset.Value.OnlineID == 0); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); + AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -467,12 +553,12 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("change ruleset", () => { SelectedMods.ValueChanged += onModChange; - songSelect.Ruleset.ValueChanged += onRulesetChange; + songSelect!.Ruleset.ValueChanged += onRulesetChange; Ruleset.Value = new TaikoRuleset().RulesetInfo; SelectedMods.ValueChanged -= onModChange; - songSelect.Ruleset.ValueChanged -= onRulesetChange; + songSelect!.Ruleset.ValueChanged -= onRulesetChange; }); AddAssert("mods changed before ruleset", () => modChangeIndex < rulesetChangeIndex); @@ -503,18 +589,18 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addManyTestMaps(); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); bool startRequested = false; AddStep("set filter and finalize", () => { - songSelect.StartRequested = () => startRequested = true; + songSelect!.StartRequested = () => startRequested = true; - songSelect.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); - songSelect.FinaliseSelection(); + songSelect!.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); + songSelect!.FinaliseSelection(); - songSelect.StartRequested = null; + songSelect!.StartRequested = null; }); AddAssert("start not requested", () => !startRequested); @@ -534,15 +620,15 @@ namespace osu.Game.Tests.Visual.SongSelect // used for filter check below AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - BeatmapInfo target = null; + BeatmapInfo? target = null; int targetRuleset = differentRuleset ? 1 : 0; @@ -556,24 +642,24 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); AddAssert("selected only shows expected ruleset (plus converts)", () => { - var selectedPanel = songSelect.Carousel.ChildrenOfType().First(s => s.Item.State.Value == CarouselItemState.Selected); + var selectedPanel = songSelect!.Carousel.ChildrenOfType().First(s => s.Item.State.Value == CarouselItemState.Selected); // special case for converts checked here. return selectedPanel.ChildrenOfType().All(i => i.IsFiltered || i.Item.BeatmapInfo.Ruleset.OnlineID == targetRuleset || i.Item.BeatmapInfo.Ruleset.OnlineID == 0); }); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); + AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("reset filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = string.Empty); + AddStep("reset filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = string.Empty); AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.MatchesOnlineID(target) == true); - AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); + AddAssert("carousel still correct", () => songSelect!.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -586,15 +672,15 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(0); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap externally", () => { @@ -606,15 +692,15 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); + AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nononoo"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nononoo"); AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); - AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddAssert("carousel lost selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -635,11 +721,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("no mods selected", () => songSelect.Mods.Value.Count == 0); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); } [Test] @@ -662,11 +748,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("autoplay still selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay still selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); } [Test] @@ -689,11 +775,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("only autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("only autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("relax returned", () => songSelect.Mods.Value.Single() is ModRelax); + AddAssert("relax returned", () => songSelect!.Mods.Value.Single() is ModRelax); } [Test] @@ -702,10 +788,10 @@ namespace osu.Game.Tests.Visual.SongSelect Guid? previousID = null; createSongSelect(); addRulesetImportStep(0); - AddStep("Move to last difficulty", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.Last())); - AddStep("Store current ID", () => previousID = songSelect.Carousel.SelectedBeatmapInfo.ID); - AddStep("Hide first beatmap", () => manager.Hide(songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First())); - AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmapInfo.ID == previousID); + AddStep("Move to last difficulty", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.Last())); + AddStep("Store current ID", () => previousID = songSelect!.Carousel.SelectedBeatmapInfo!.ID); + AddStep("Hide first beatmap", () => manager.Hide(songSelect!.Carousel.SelectedBeatmapSet!.Beatmaps.First())); + AddAssert("Selected beatmap has not changed", () => songSelect!.Carousel.SelectedBeatmapInfo?.ID == previousID); } [Test] @@ -716,17 +802,24 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet set = null!; AddStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().First(); + set = songSelect!.Carousel.ChildrenOfType().First(); }); - FilterableDifficultyIcon difficultyIcon = null; + FilterableDifficultyIcon difficultyIcon = null!; + AddUntilStep("Find an icon", () => { - return (difficultyIcon = set.ChildrenOfType() - .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null; + var foundIcon = set.ChildrenOfType() + .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + + if (foundIcon == null) + return false; + + difficultyIcon = foundIcon; + return true; }); AddStep("Click on a difficulty", () => @@ -739,21 +832,24 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selected beatmap correct", () => getCurrentBeatmapIndex() == getDifficultyIconIndex(set, difficultyIcon)); double? maxBPM = null; - AddStep("Filter some difficulties", () => songSelect.Carousel.Filter(new FilterCriteria + AddStep("Filter some difficulties", () => songSelect!.Carousel.Filter(new FilterCriteria { BPM = new FilterCriteria.OptionalRange { - Min = maxBPM = songSelect.Carousel.SelectedBeatmapSet.MaxBPM, + Min = maxBPM = songSelect!.Carousel.SelectedBeatmapSet!.MaxBPM, IsLowerInclusive = true } })); - BeatmapInfo filteredBeatmap = null; - FilterableDifficultyIcon filteredIcon = null; + BeatmapInfo? filteredBeatmap = null; + FilterableDifficultyIcon? filteredIcon = null; AddStep("Get filtered icon", () => { - var selectedSet = songSelect.Carousel.SelectedBeatmapSet; + var selectedSet = songSelect!.Carousel.SelectedBeatmapSet; + + Debug.Assert(selectedSet != null); + filteredBeatmap = selectedSet.Beatmaps.First(b => b.BPM < maxBPM); int filteredBeatmapIndex = getBeatmapIndex(selectedSet, filteredBeatmap); filteredIcon = set.ChildrenOfType().ElementAt(filteredBeatmapIndex); @@ -766,7 +862,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Click(MouseButton.Left); }); - AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(filteredBeatmap)); + AddAssert("Selected beatmap correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(filteredBeatmap) == true); } [Test] @@ -831,14 +927,14 @@ namespace osu.Game.Tests.Visual.SongSelect manager.Import(TestResources.CreateTestBeatmapSetInfo(3, usableRulesets)); }); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet? set = null; AddUntilStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); return set != null; }); - FilterableDifficultyIcon difficultyIcon = null; + FilterableDifficultyIcon? difficultyIcon = null; AddUntilStep("Find an icon for different ruleset", () => { difficultyIcon = set.ChildrenOfType() @@ -861,7 +957,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.OnlineID == 3); - AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmapInfo.BeatmapSet?.OnlineID == previousSetID); + AddAssert("Selected beatmap still same set", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == previousSetID); AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID == 3); } @@ -872,7 +968,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - BeatmapSetInfo imported = null; + BeatmapSetInfo? imported = null; AddStep("import huge difficulty count map", () => { @@ -880,20 +976,27 @@ namespace osu.Game.Tests.Visual.SongSelect imported = manager.Import(TestResources.CreateTestBeatmapSetInfo(50, usableRulesets))?.Value; }); - AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported.Beatmaps.First())); + AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported?.Beatmaps.First())); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet? set = null; AddUntilStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); return set != null; }); - GroupedDifficultyIcon groupIcon = null; + GroupedDifficultyIcon groupIcon = null!; + AddUntilStep("Find group icon for different ruleset", () => { - return (groupIcon = set.ChildrenOfType() - .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3)) != null; + var foundIcon = set.ChildrenOfType() + .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3); + + if (foundIcon == null) + return false; + + groupIcon = foundIcon; + return true; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.OnlineID == 0); @@ -928,7 +1031,7 @@ namespace osu.Game.Tests.Visual.SongSelect // this ruleset change should be overridden by the present. Ruleset.Value = getSwitchBeatmap().Ruleset; - songSelect.PresentScore(new ScoreInfo + songSelect!.PresentScore(new ScoreInfo { User = new APIUser { Username = "woo" }, BeatmapInfo = getPresentBeatmap(), @@ -936,7 +1039,7 @@ namespace osu.Game.Tests.Visual.SongSelect }); }); - AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -962,10 +1065,10 @@ namespace osu.Game.Tests.Visual.SongSelect // this beatmap change should be overridden by the present. Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); - songSelect.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); + songSelect!.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); }); - AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -978,23 +1081,29 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("toggle mod overlay on", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay shown", () => songSelect.ModSelect.State.Value == Visibility.Visible); + AddUntilStep("mod overlay shown", () => songSelect!.ModSelect.State.Value == Visibility.Visible); AddStep("toggle mod overlay off", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay hidden", () => songSelect.ModSelect.State.Value == Visibility.Hidden); + AddUntilStep("mod overlay hidden", () => songSelect!.ModSelect.State.Value == Visibility.Hidden); } private void waitForInitialSelection() { AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - AddUntilStep("wait for difficulty panels visible", () => songSelect.Carousel.ChildrenOfType().Any()); + AddUntilStep("wait for difficulty panels visible", () => songSelect!.Carousel.ChildrenOfType().Any()); } private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.IndexOf(info); - private NoResultsPlaceholder getPlaceholder() => songSelect.ChildrenOfType().FirstOrDefault(); + private NoResultsPlaceholder? getPlaceholder() => songSelect!.ChildrenOfType().FirstOrDefault(); - private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmapInfo); + private int getCurrentBeatmapIndex() + { + Debug.Assert(songSelect!.Carousel.SelectedBeatmapSet != null); + Debug.Assert(songSelect!.Carousel.SelectedBeatmapInfo != null); + + return getBeatmapIndex(songSelect!.Carousel.SelectedBeatmapSet, songSelect!.Carousel.SelectedBeatmapInfo); + } private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) { @@ -1003,14 +1112,14 @@ namespace osu.Game.Tests.Visual.SongSelect private void addRulesetImportStep(int id) { - Live imported = null; + Live? imported = null; AddStep($"import test map for ruleset {id}", () => imported = importForRuleset(id)); // This is specifically for cases where the add is happening post song select load. // For cases where song select is null, the assertions are provided by the load checks. - AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); + AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect!.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); } - private Live importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); + private Live? importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); private void checkMusicPlaying(bool playing) => AddUntilStep($"music {(playing ? "" : "not ")}playing", () => music.IsPlaying == playing); @@ -1022,8 +1131,8 @@ namespace osu.Game.Tests.Visual.SongSelect private void createSongSelect() { AddStep("create song select", () => LoadScreen(songSelect = new TestSongSelect())); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); - AddUntilStep("wait for carousel loaded", () => songSelect.Carousel.IsAlive); + AddUntilStep("wait for present", () => songSelect!.IsCurrentScreen()); + AddUntilStep("wait for carousel loaded", () => songSelect!.Carousel.IsAlive); } /// @@ -1047,12 +1156,14 @@ namespace osu.Game.Tests.Visual.SongSelect protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - rulesets?.Dispose(); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); } private class TestSongSelect : PlaySongSelect { - public Action StartRequested; + public Action? StartRequested; public new Bindable Ruleset => base.Ruleset; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs index 8f890b2383..cced9b8b89 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs @@ -1,8 +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 - +using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -13,6 +12,7 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Select.Carousel; using osu.Game.Tests.Resources; @@ -22,17 +22,17 @@ namespace osu.Game.Tests.Visual.SongSelect { public class TestSceneTopLocalRank : OsuTestScene { - private RulesetStore rulesets; - private BeatmapManager beatmapManager; - private ScoreManager scoreManager; - private TopLocalRank topLocalRank; + private RulesetStore rulesets = null!; + private BeatmapManager beatmapManager = null!; + private ScoreManager scoreManager = null!; + private TopLocalRank topLocalRank = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmapManager, LocalStorage, Realm, Scheduler)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); @@ -47,21 +47,21 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Create local rank", () => { - Add(topLocalRank = new TopLocalRank(importedBeatmap) + Child = topLocalRank = new TopLocalRank(importedBeatmap) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Scale = new Vector2(10), - }); + }; }); + + AddAssert("No rank displayed initially", () => topLocalRank.DisplayedRank == null); } [Test] public void TestBasicImportDelete() { - ScoreInfo testScoreInfo = null; - - AddAssert("Initially not present", () => !topLocalRank.IsPresent); + ScoreInfo testScoreInfo = null!; AddStep("Add score for current user", () => { @@ -73,25 +73,19 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo); }); - AddUntilStep("Became present", () => topLocalRank.IsPresent); - AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.B); + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - AddStep("Delete score", () => - { - scoreManager.Delete(testScoreInfo); - }); + AddStep("Delete score", () => scoreManager.Delete(testScoreInfo)); - AddUntilStep("Became not present", () => !topLocalRank.IsPresent); + AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank == null); } [Test] public void TestRulesetChange() { - ScoreInfo testScoreInfo; - AddStep("Add score for current user", () => { - testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); + var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo.User = API.LocalUser.Value; testScoreInfo.Rank = ScoreRank.B; @@ -99,25 +93,21 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo); }); - AddUntilStep("Wait for initial presence", () => topLocalRank.IsPresent); + AddUntilStep("Wait for initial display", () => topLocalRank.DisplayedRank == ScoreRank.B); AddStep("Change ruleset", () => Ruleset.Value = rulesets.GetRuleset("fruits")); - AddUntilStep("Became not present", () => !topLocalRank.IsPresent); + AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank == null); AddStep("Change ruleset back", () => Ruleset.Value = rulesets.GetRuleset("osu")); - AddUntilStep("Became present", () => topLocalRank.IsPresent); + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); } [Test] public void TestHigherScoreSet() { - ScoreInfo testScoreInfo = null; - - AddAssert("Initially not present", () => !topLocalRank.IsPresent); - AddStep("Add score for current user", () => { - testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); + var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo.User = API.LocalUser.Value; testScoreInfo.Rank = ScoreRank.B; @@ -125,21 +115,58 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo); }); - AddUntilStep("Became present", () => topLocalRank.IsPresent); - AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.B); + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); AddStep("Add higher score for current user", () => { var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo2.User = API.LocalUser.Value; - testScoreInfo2.Rank = ScoreRank.S; - testScoreInfo2.TotalScore = testScoreInfo.TotalScore + 1; + testScoreInfo2.Rank = ScoreRank.X; + testScoreInfo2.TotalScore = 1000000; + testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; scoreManager.Import(testScoreInfo2); }); - AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.S); + AddUntilStep("SS rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.X); + } + + [Test] + public void TestLegacyScore() + { + ScoreInfo testScoreInfo = null!; + + AddStep("Add legacy score for current user", () => + { + testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); + + testScoreInfo.User = API.LocalUser.Value; + testScoreInfo.Rank = ScoreRank.B; + testScoreInfo.TotalScore = scoreManager.GetTotalScore(testScoreInfo, ScoringMode.Classic); + + scoreManager.Import(testScoreInfo); + }); + + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); + + AddStep("Add higher score for current user", () => + { + var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); + + testScoreInfo2.User = API.LocalUser.Value; + testScoreInfo2.Rank = ScoreRank.X; + testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; + testScoreInfo2.TotalScore = scoreManager.GetTotalScore(testScoreInfo2); + + // ensure second score has a total score (standardised) less than first one (classic) + // despite having better statistics, otherwise this test is pointless. + Debug.Assert(testScoreInfo2.TotalScore < testScoreInfo.TotalScore); + + scoreManager.Import(testScoreInfo2); + }); + + AddUntilStep("SS rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.X); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs new file mode 100644 index 0000000000..96cfbe4dd1 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs @@ -0,0 +1,207 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Carousel; +using osu.Game.Tests.Online; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public class TestSceneUpdateBeatmapSetButton : OsuManualInputManagerTestScene + { + private BeatmapCarousel carousel = null!; + + private TestSceneOnlinePlayBeatmapAvailabilityTracker.TestBeatmapModelDownloader beatmapDownloader = null!; + + private BeatmapSetInfo testBeatmapSetInfo = null!; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + var importer = parent.Get(); + + dependencies.CacheAs(beatmapDownloader = new TestSceneOnlinePlayBeatmapAvailabilityTracker.TestBeatmapModelDownloader(importer, API)); + return dependencies; + } + + private UpdateBeatmapSetButton? getUpdateButton() => carousel.ChildrenOfType().SingleOrDefault(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create carousel", () => Child = createCarousel()); + + AddUntilStep("wait for load", () => carousel.BeatmapSetsLoaded); + + AddAssert("update button not visible", () => getUpdateButton() == null); + } + + [Test] + public void TestDownloadToCompletion() + { + ArchiveDownloadRequest? downloadRequest = null; + + AddStep("update online hash", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("only one set visible", () => carousel.ChildrenOfType().Count() == 1); + AddUntilStep("update button visible", () => getUpdateButton() != null); + + AddStep("click button", () => getUpdateButton()?.TriggerClick()); + + AddUntilStep("wait for download started", () => + { + downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); + return downloadRequest != null; + }); + + AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); + + AddUntilStep("progress download to completion", () => + { + if (downloadRequest is TestSceneOnlinePlayBeatmapAvailabilityTracker.TestDownloadRequest testRequest) + { + testRequest.SetProgress(testRequest.Progress + 0.1f); + + if (testRequest.Progress >= 1) + { + testRequest.TriggerSuccess(); + + // usually this would be done by the import process. + testBeatmapSetInfo.Beatmaps.First().MD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + // usually this would be done by a realm subscription. + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + return true; + } + } + + return false; + }); + } + + [Test] + public void TestDownloadFailed() + { + ArchiveDownloadRequest? downloadRequest = null; + + AddStep("update online hash", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("only one set visible", () => carousel.ChildrenOfType().Count() == 1); + AddUntilStep("update button visible", () => getUpdateButton() != null); + + AddStep("click button", () => getUpdateButton()?.TriggerClick()); + + AddUntilStep("wait for download started", () => + { + downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); + return downloadRequest != null; + }); + + AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); + + AddUntilStep("progress download to failure", () => + { + if (downloadRequest is TestSceneOnlinePlayBeatmapAvailabilityTracker.TestDownloadRequest testRequest) + { + testRequest.SetProgress(testRequest.Progress + 0.1f); + + if (testRequest.Progress >= 0.5f) + { + testRequest.TriggerFailure(new Exception()); + return true; + } + } + + return false; + }); + + AddUntilStep("wait for button enabled", () => getUpdateButton()?.Enabled.Value == true); + } + + [Test] + public void TestUpdateLocalBeatmap() + { + DialogOverlay dialogOverlay = null!; + UpdateBeatmapSetButton? updateButton = null; + + AddStep("create carousel with dialog overlay", () => + { + dialogOverlay = new DialogOverlay(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(IDialogOverlay), dialogOverlay), }, + Children = new Drawable[] + { + createCarousel(), + dialogOverlay, + }, + }; + }); + + AddStep("setup beatmap state", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + testBeatmapSetInfo.Status = BeatmapOnlineStatus.LocallyModified; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("wait for update button", () => (updateButton = getUpdateButton()) != null); + AddStep("click button", () => updateButton.AsNonNull().TriggerClick()); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is UpdateLocalConfirmationDialog); + AddStep("click confirmation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); + InputManager.PressButton(MouseButton.Left); + }); + + AddUntilStep("update started", () => beatmapDownloader.GetExistingDownload(testBeatmapSetInfo) != null); + AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + } + + private BeatmapCarousel createCarousel() + { + return carousel = new BeatmapCarousel + { + RelativeSizeAxes = Axes.Both, + BeatmapSets = new List + { + (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo()), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs index 16966e489a..39fd9fda2b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs @@ -69,11 +69,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, }, new ScoreInfo @@ -88,11 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 4608074, Username = @"Skycries", - Country = new Country - { - FullName = @"Brazil", - FlagName = @"BR", - }, + CountryCode = CountryCode.BR, }, }, new ScoreInfo @@ -107,11 +99,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 1541390, Username = @"Toukai", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, } }; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index 44f2da2b95..e8454e8d0f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -4,13 +4,13 @@ #nullable disable using System.Linq; -using Humanizer; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; control.Query.BindValueChanged(q => query.Text = $"Query: {q.NewValue}", true); - control.General.BindCollectionChanged((_, _) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().Underscore())) : "")}", true); + control.General.BindCollectionChanged((_, _) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().ToSnakeCase())) : "")}", true); control.Ruleset.BindValueChanged(r => ruleset.Text = $"Ruleset: {r.NewValue}", true); control.Category.BindValueChanged(c => category.Text = $"Category: {c.NewValue}", true); control.Genre.BindValueChanged(g => genre.Text = $"Genre: {g.NewValue}", true); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs index 3247461fba..0ef13385ec 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs @@ -3,9 +3,13 @@ #nullable disable +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; @@ -15,12 +19,13 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneBeatmapListingSortTabControl : OsuTestScene { + private readonly BeatmapListingSortTabControl control; + [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public TestSceneBeatmapListingSortTabControl() { - BeatmapListingSortTabControl control; OsuSpriteText current; OsuSpriteText direction; @@ -45,5 +50,83 @@ namespace osu.Game.Tests.Visual.UserInterface control.SortDirection.BindValueChanged(sortDirection => direction.Text = $"Sort direction: {sortDirection.NewValue}", true); control.Current.BindValueChanged(criteria => current.Text = $"Criteria: {criteria.NewValue}", true); } + + [Test] + public void TestRankedSort() + { + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Any); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Leaderboard); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Ranked); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Qualified); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Loved); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Favourites); + criteriaShowsOnCategory(false, SortCriteria.Ranked, SearchCategory.Pending); + criteriaShowsOnCategory(false, SortCriteria.Ranked, SearchCategory.Wip); + criteriaShowsOnCategory(false, SortCriteria.Ranked, SearchCategory.Graveyard); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Mine); + } + + [Test] + public void TestUpdatedSort() + { + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Any); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Leaderboard); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Ranked); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Qualified); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Loved); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Favourites); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Pending); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Wip); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Graveyard); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Mine); + } + + [Test] + public void TestNominationsSort() + { + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Any); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Leaderboard); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Ranked); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Qualified); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Loved); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Favourites); + criteriaShowsOnCategory(true, SortCriteria.Nominations, SearchCategory.Pending); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Wip); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Graveyard); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Mine); + } + + [Test] + public void TestResetNoQuery() + { + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Any); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Leaderboard); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Ranked); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Qualified); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Loved); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Favourites); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Pending); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Wip); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Graveyard); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Mine); + } + + private void criteriaShowsOnCategory(bool expected, SortCriteria criteria, SearchCategory category) + { + AddAssert($"{criteria.ToString().ToLowerInvariant()} {(expected ? "shown" : "not shown")} on {category.ToString().ToLowerInvariant()}", () => + { + control.Reset(category, false); + return control.ChildrenOfType>().Single().Items.Contains(criteria) == expected; + }); + } + + private void resetUsesCriteriaOnCategory(SortCriteria criteria, SearchCategory category) + { + AddAssert($"reset uses {criteria.ToString().ToLowerInvariant()} on {category.ToString().ToLowerInvariant()}", () => + { + control.Reset(category, false); + return control.Current.Value == criteria; + }); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs index 0fa7c5303c..75c47f0b1b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs @@ -15,18 +15,19 @@ using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] public class TestSceneCursors : OsuManualInputManagerTestScene { - private readonly MenuCursorContainer menuCursorContainer; + private readonly GlobalCursorDisplay globalCursorDisplay; private readonly CustomCursorBox[] cursorBoxes = new CustomCursorBox[6]; public TestSceneCursors() { - Child = menuCursorContainer = new MenuCursorContainer + Child = globalCursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both, Children = new[] @@ -81,26 +82,25 @@ namespace osu.Game.Tests.Visual.UserInterface }; AddToggleStep("Smooth transitions", b => cursorBoxes.ForEach(box => box.SmoothTransition = b)); - - testUserCursor(); - testLocalCursor(); - testUserCursorOverride(); - testMultipleLocalCursors(); } + [SetUp] + public void SetUp() => Schedule(moveOut); + /// /// -- Green Box -- /// Tests whether hovering in and out of a drawable that provides the user cursor (green) /// results in the correct visibility state for that cursor. /// - private void testUserCursor() + [Test] + public void TestUserCursor() { AddStep("Move to green area", () => InputManager.MoveMouseTo(cursorBoxes[0])); AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].Cursor)); AddAssert("Check green cursor at mouse", () => checkAtMouse(cursorBoxes[0].Cursor)); AddStep("Move out", moveOut); AddAssert("Check green cursor invisible", () => !checkVisible(cursorBoxes[0].Cursor)); - AddAssert("Check global cursor visible", () => checkVisible(menuCursorContainer.Cursor)); + AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); } /// @@ -108,16 +108,17 @@ namespace osu.Game.Tests.Visual.UserInterface /// Tests whether hovering in and out of a drawable that provides a local cursor (purple) /// results in the correct visibility and state for that cursor. /// - private void testLocalCursor() + [Test] + public void TestLocalCursor() { AddStep("Move to purple area", () => InputManager.MoveMouseTo(cursorBoxes[3])); AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); AddAssert("Check purple cursor at mouse", () => checkAtMouse(cursorBoxes[3].Cursor)); - AddAssert("Check global cursor visible", () => checkVisible(menuCursorContainer.Cursor)); - AddAssert("Check global cursor at mouse", () => checkAtMouse(menuCursorContainer.Cursor)); + AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); + AddAssert("Check global cursor at mouse", () => checkAtMouse(globalCursorDisplay.MenuCursor)); AddStep("Move out", moveOut); AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); - AddAssert("Check global cursor visible", () => checkVisible(menuCursorContainer.Cursor)); + AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); } /// @@ -125,7 +126,8 @@ namespace osu.Game.Tests.Visual.UserInterface /// Tests whether overriding a user cursor (green) with another user cursor (blue) /// results in the correct visibility and states for the cursors. /// - private void testUserCursorOverride() + [Test] + public void TestUserCursorOverride() { AddStep("Move to blue-green boundary", () => InputManager.MoveMouseTo(cursorBoxes[1].ScreenSpaceDrawQuad.BottomRight - new Vector2(10))); AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].Cursor)); @@ -140,7 +142,8 @@ namespace osu.Game.Tests.Visual.UserInterface /// -- Yellow-Purple Box Boundary -- /// Tests whether multiple local cursors (purple + yellow) may be visible and at the mouse position at the same time. /// - private void testMultipleLocalCursors() + [Test] + public void TestMultipleLocalCursors() { AddStep("Move to yellow-purple boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.BottomRight - new Vector2(10))); AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); @@ -156,9 +159,10 @@ namespace osu.Game.Tests.Visual.UserInterface /// -- Yellow-Blue Box Boundary -- /// Tests whether a local cursor (yellow) may be displayed along with a user cursor override (blue). /// - private void testUserOverrideWithLocal() + [Test] + public void TestUserOverrideWithLocal() { - AddStep("Move to yellow-blue boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.TopRight - new Vector2(10))); + AddStep("Move to yellow-blue boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.TopRight - new Vector2(10, 0))); AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].Cursor)); AddAssert("Check blue cursor at mouse", () => checkAtMouse(cursorBoxes[1].Cursor)); AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); @@ -168,6 +172,54 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); } + /// + /// Ensures non-mouse input hides global cursor on a "local cursor" area (which doesn't hide global cursor). + /// + [Test] + public void TestKeyboardLocalCursor([Values] bool clickToShow) + { + AddStep("Enable cursor hiding", () => globalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = true); + AddStep("Move to purple area", () => InputManager.MoveMouseTo(cursorBoxes[3].ScreenSpaceDrawQuad.Centre + new Vector2(10, 0))); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddAssert("Check global cursor alpha is 1", () => globalCursorDisplay.MenuCursor.Alpha == 1); + + AddStep("Press key", () => InputManager.Key(Key.A)); + AddAssert("Check purple cursor still visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddUntilStep("Check global cursor alpha is 0", () => globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + if (clickToShow) + AddStep("Click mouse", () => InputManager.Click(MouseButton.Left)); + else + AddStep("Move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + Vector2.One)); + + AddAssert("Check purple cursor still visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddUntilStep("Check global cursor alpha is 1", () => globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 1); + } + + /// + /// Ensures mouse input after non-mouse input doesn't show global cursor on a "user cursor" area (which hides global cursor). + /// + [Test] + public void TestKeyboardUserCursor([Values] bool clickToShow) + { + AddStep("Enable cursor hiding", () => globalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = true); + AddStep("Move to green area", () => InputManager.MoveMouseTo(cursorBoxes[0])); + AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + AddStep("Press key", () => InputManager.Key(Key.A)); + AddAssert("Check green cursor still visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is still 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + if (clickToShow) + AddStep("Click mouse", () => InputManager.Click(MouseButton.Left)); + else + AddStep("Move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + Vector2.One)); + + AddAssert("Check green cursor still visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is still 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + } + /// /// Moves the cursor to a point not covered by any cursor containers. /// diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index cee917f6cf..c1a9768cf0 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -37,7 +37,6 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly ContextMenuContainer contextMenuContainer; private readonly BeatmapLeaderboard leaderboard; - private RulesetStore rulesetStore; private BeatmapManager beatmapManager; private ScoreManager scoreManager; @@ -72,9 +71,9 @@ namespace osu.Game.Tests.Visual.UserInterface { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); - dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get(), () => beatmapManager, LocalStorage, Realm, Scheduler)); + dependencies.Cache(new RealmRulesetStore(Realm)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get(), () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); return dependencies; @@ -164,7 +163,7 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.PressButton(MouseButton.Left); }); - AddUntilStep("wait for fetch", () => leaderboard.Scores != null); + AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); // "Clean up" @@ -175,7 +174,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestDeleteViaDatabase() { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); - AddUntilStep("wait for fetch", () => leaderboard.Scores != null); + AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != importedScores[0].OnlineID)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs new file mode 100644 index 0000000000..d78707045b --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFPSCounter : OsuTestScene + { + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create display", () => + { + Children = new Drawable[] + { + new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new FPSCounter(), + new FPSCounter { Scale = new Vector2(2) }, + new FPSCounter { Scale = new Vector2(4) }, + } + }, + }; + }); + } + + [Test] + public void TestBasic() + { + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index b845b85e1f..16d564f0ee 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -194,7 +194,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("notification arrived", () => notificationOverlay.Verify(n => n.Post(It.IsAny()), Times.Once)); - AddStep("run notification action", () => lastNotification.Activated()); + AddStep("run notification action", () => lastNotification.Activated?.Invoke()); AddAssert("overlay shown", () => overlay.State.Value == Visibility.Visible); AddAssert("is resumed", () => overlay.CurrentScreen is ScreenUIScale); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs index 8ccfe2ee9c..e5f3aea2f7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs @@ -8,8 +8,10 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; @@ -17,11 +19,26 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneLabelledSliderBar : OsuTestScene { - [TestCase(false)] - [TestCase(true)] - public void TestSliderBar(bool hasDescription) => createSliderBar(hasDescription); + [Test] + public void TestBasic() => createSliderBar(); - private void createSliderBar(bool hasDescription = false) + [Test] + public void TestDescription() + { + createSliderBar(); + AddStep("set description", () => this.ChildrenOfType>().ForEach(l => l.Description = "this text describes the component")); + } + + [Test] + public void TestSize() + { + createSliderBar(); + AddStep("set zero width", () => this.ChildrenOfType>().ForEach(l => l.ResizeWidthTo(0, 200, Easing.OutQuint))); + AddStep("set negative width", () => this.ChildrenOfType>().ForEach(l => l.ResizeWidthTo(-1, 200, Easing.OutQuint))); + AddStep("revert back", () => this.ChildrenOfType>().ForEach(l => l.ResizeWidthTo(1, 200, Easing.OutQuint))); + } + + private void createSliderBar() { AddStep("create component", () => { @@ -38,6 +55,8 @@ namespace osu.Game.Tests.Visual.UserInterface { new LabelledSliderBar { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Current = new BindableDouble(5) { MinValue = 0, @@ -45,7 +64,6 @@ namespace osu.Game.Tests.Visual.UserInterface Precision = 1, }, Label = "a sample component", - Description = hasDescription ? "this text describes the component" : string.Empty, }, }, }; @@ -54,10 +72,14 @@ namespace osu.Game.Tests.Visual.UserInterface { flow.Add(new OverlayColourContainer(colour) { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Child = new LabelledSliderBar { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Current = new BindableDouble(5) { MinValue = 0, @@ -65,7 +87,6 @@ namespace osu.Game.Tests.Visual.UserInterface Precision = 1, }, Label = "a sample component", - Description = hasDescription ? "this text describes the component" : string.Empty, } }); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index 558ea01a49..d069e742dd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -259,7 +259,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void removeFacade() { - trackingContainer.Remove(logoFacade); + trackingContainer.Remove(logoFacade, false); visualBox.Colour = Color4.White; moveLogoFacade(); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs index 0a0415789a..ce9aa682d1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -1,10 +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.Linq; using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; @@ -13,10 +13,24 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneModIcon : OsuTestScene { + [Test] + public void TestShowAllMods() + { + AddStep("create mod icons", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Full, + ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods().Select(m => new ModIcon(m)), + }; + }); + } + [Test] public void TestChangeModType() { - ModIcon icon = null; + ModIcon icon = null!; AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); AddStep("change mod", () => icon.Mod = new OsuModEasy()); @@ -25,7 +39,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestInterfaceModType() { - ModIcon icon = null; + ModIcon icon = null!; var ruleset = new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs new file mode 100644 index 0000000000..901f234db6 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -0,0 +1,314 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModPresetColumn : OsuManualInputManagerTestScene + { + protected override bool UseFreshStoragePerRun => true; + + private Container content = null!; + protected override Container Content => content; + + private RulesetStore rulesets = null!; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Cached(typeof(IDialogOverlay))] + private readonly DialogOverlay dialogOverlay = new DialogOverlay(); + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(Realm); + + base.Content.AddRange(new Drawable[] + { + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = content = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + } + }, + dialogOverlay + }); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear contents", Clear); + AddStep("reset storage", () => + { + Realm.Write(realm => + { + realm.RemoveAll(); + + var testPresets = createTestPresets(); + foreach (var preset in testPresets) + preset.Ruleset = realm.Find(preset.Ruleset.ShortName); + + realm.Add(testPresets); + }); + }); + } + + [Test] + public void TestBasicOperation() + { + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddStep("create content", () => Child = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + + AddStep("change ruleset to mania", () => Ruleset.Value = rulesets.GetRuleset(3)); + AddUntilStep("1 panel visible", () => this.ChildrenOfType().Count() == 1); + + AddStep("add another mania preset", () => Realm.Write(r => r.Add(new ModPreset + { + Name = "and another one", + Mods = new Mod[] + { + new ManiaModMirror(), + new ManiaModNightcore(), + new ManiaModHardRock() + }, + Ruleset = r.Find("mania") + }))); + AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); + + AddStep("add another osu! preset", () => Realm.Write(r => r.Add(new ModPreset + { + Name = "hdhr", + Mods = new Mod[] + { + new OsuModHidden(), + new OsuModHardRock() + }, + Ruleset = r.Find("osu") + }))); + AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); + + AddStep("remove mania preset", () => Realm.Write(r => + { + var toRemove = r.All().Single(preset => preset.Name == "Different ruleset"); + r.Remove(toRemove); + })); + AddUntilStep("1 panel visible", () => this.ChildrenOfType().Count() == 1); + + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddUntilStep("4 panels visible", () => this.ChildrenOfType().Count() == 4); + } + + [Test] + public void TestSoftDeleteSupport() + { + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + + AddStep("soft delete preset", () => Realm.Write(r => + { + var toSoftDelete = r.All().Single(preset => preset.Name == "AR0"); + toSoftDelete.DeletePending = true; + })); + AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); + + AddStep("soft delete all presets", () => Realm.Write(r => + { + foreach (var preset in r.All()) + preset.DeletePending = true; + })); + AddUntilStep("no panels visible", () => !this.ChildrenOfType().Any()); + + AddStep("select mods from first preset", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHardRock() }); + + AddStep("undelete presets", () => Realm.Write(r => + { + foreach (var preset in r.All()) + preset.DeletePending = false; + })); + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + } + + [Test] + public void TestAddingFlow() + { + ModPresetColumn modPresetColumn = null!; + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + AddAssert("add preset button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModDaycore(), new OsuModClassic() }); + AddAssert("add preset button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + + AddStep("click add preset button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + OsuPopover? popover = null; + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("attempt preset creation", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddWaitStep("wait some", 3); + AddAssert("preset creation did not occur", () => this.ChildrenOfType().Count() == 3); + AddUntilStep("popover is unchanged", () => this.ChildrenOfType().FirstOrDefault() == popover); + + AddStep("fill preset name", () => popover.ChildrenOfType().First().Current.Value = "new preset"); + AddStep("attempt preset creation", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); + AddUntilStep("preset creation occurred", () => this.ChildrenOfType().Count() == 4); + + AddStep("click add preset button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestDeleteFlow() + { + ModPresetColumn modPresetColumn = null!; + + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + AddStep("right click first panel", () => + { + var panel = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click delete", () => + { + var deleteItem = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(deleteItem); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for dialog", () => dialogOverlay.CurrentDialog is DeleteModPresetDialog); + AddStep("hold confirm", () => + { + var confirmButton = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(confirmButton); + InputManager.PressButton(MouseButton.Left); + }); + AddUntilStep("wait for dialog to close", () => dialogOverlay.CurrentDialog == null); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddUntilStep("preset deletion occurred", () => this.ChildrenOfType().Count() == 2); + AddAssert("preset soft-deleted", () => Realm.Run(r => r.All().Count(preset => preset.DeletePending) == 1)); + } + + private ICollection createTestPresets() => new[] + { + new ModPreset + { + Name = "First preset", + Description = "Please ignore", + Mods = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime() + }, + Ruleset = rulesets.GetRuleset(0).AsNonNull() + }, + new ModPreset + { + Name = "AR0", + Description = "For good readers", + Mods = new Mod[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 0 } + } + }, + Ruleset = rulesets.GetRuleset(0).AsNonNull() + }, + new ModPreset + { + Name = "This preset is going to have an extraordinarily long name", + Description = "This is done so that the capability to truncate overlong texts may be demonstrated", + Mods = new Mod[] + { + new OsuModFlashlight(), + new OsuModSpinIn() + }, + Ruleset = rulesets.GetRuleset(0).AsNonNull() + }, + new ModPreset + { + Name = "Different ruleset", + Description = "Just to shake things up", + Mods = new Mod[] + { + new ManiaModKey4(), + new ManiaModFadeIn() + }, + Ruleset = rulesets.GetRuleset(3).AsNonNull() + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs new file mode 100644 index 0000000000..bcd5579f03 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs @@ -0,0 +1,160 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneModPresetPanel : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset selected mods", () => SelectedMods.SetDefault()); + } + + [Test] + public void TestVariousModPresets() + { + AddStep("create content", () => Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(0, 5), + ChildrenEnumerable = createTestPresets().Select(preset => new ModPresetPanel(preset.ToLiveUnmanaged())) + }); + } + + [Test] + public void TestPresetSelectionStateAfterExternalModChanges() + { + ModPresetPanel? panel = null; + + AddStep("create panel", () => Child = panel = new ModPresetPanel(createTestPresets().First().ToLiveUnmanaged()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f + }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HR", () => SelectedMods.Value = new[] { new OsuModHardRock() }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to DT", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + AddAssert("panel is active", () => panel.AsNonNull().Active.Value); + + AddStep("set mods to HR+customised DT", () => SelectedMods.Value = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime + { + SpeedChange = { Value = 1.25 } + } + }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + AddAssert("panel is active", () => panel.AsNonNull().Active.Value); + + AddStep("customise mod in place", () => SelectedMods.Value.OfType().Single().SpeedChange.Value = 1.33); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HD+HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + } + + [Test] + public void TestActivatingPresetTogglesIncludedMods() + { + ModPresetPanel? panel = null; + + AddStep("create panel", () => Child = panel = new ModPresetPanel(createTestPresets().First().ToLiveUnmanaged()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f + }); + + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + + AddStep("deactivate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(Array.Empty()); + + AddStep("set different mod", () => SelectedMods.Value = new[] { new OsuModHidden() }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + + AddStep("set customised mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); + } + + private void assertSelectedModsEquivalentTo(IEnumerable mods) + => AddAssert("selected mods changed correctly", () => new HashSet(SelectedMods.Value).SetEquals(mods)); + + private static IEnumerable createTestPresets() => new[] + { + new ModPreset + { + Name = "First preset", + Description = "Please ignore", + Mods = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime() + }, + Ruleset = new OsuRuleset().RulesetInfo + }, + new ModPreset + { + Name = "AR0", + Description = "For good readers", + Mods = new Mod[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 0 } + } + }, + Ruleset = new OsuRuleset().RulesetInfo + }, + new ModPreset + { + Name = "This preset is going to have an extraordinarily long name", + Description = "This is done so that the capability to truncate overlong texts may be demonstrated", + Mods = new Mod[] + { + new OsuModFlashlight(), + new OsuModSpinIn() + }, + Ruleset = new OsuRuleset().RulesetInfo + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 4de70f6f9e..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)); - ModColumn 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.Tests/Visual/UserInterface/TestSceneModsEffectDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModsEffectDisplay.cs new file mode 100644 index 0000000000..42eceb3242 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModsEffectDisplay.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneModsEffectDisplay : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Test] + public void TestModsEffectDisplay() + { + TestDisplay testDisplay = null!; + Box background = null!; + + AddStep("add display", () => + { + Add(testDisplay = new TestDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + var boxes = testDisplay.ChildrenOfType(); + background = boxes.First(); + }); + + AddStep("set value to default", () => testDisplay.Current.Value = 50); + AddUntilStep("colours are correct", () => testDisplay.Container.Colour == Color4.White && background.Colour == colourProvider.Background3); + + AddStep("set value to less", () => testDisplay.Current.Value = 40); + AddUntilStep("colours are correct", () => testDisplay.Container.Colour == colourProvider.Background5 && background.Colour == colours.ForModType(ModType.DifficultyReduction)); + + AddStep("set value to bigger", () => testDisplay.Current.Value = 60); + AddUntilStep("colours are correct", () => testDisplay.Container.Colour == colourProvider.Background5 && background.Colour == colours.ForModType(ModType.DifficultyIncrease)); + } + + private class TestDisplay : ModsEffectDisplay + { + public Container Container => Content; + + protected override LocalisableString Label => "Test display"; + + public TestDisplay() + { + Current.Default = 50; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index cf069a9e34..7ed08d8dff 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -1,36 +1,45 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Updater; +using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneNotificationOverlay : OsuTestScene + public class TestSceneNotificationOverlay : OsuManualInputManagerTestScene { - private NotificationOverlay notificationOverlay; + private NotificationOverlay notificationOverlay = null!; private readonly List progressingNotifications = new List(); - private SpriteText displayedCount; + private SpriteText displayedCount = null!; + + public double TimeToCompleteProgress { get; set; } = 2000; [SetUp] public void SetUp() => Schedule(() => { + InputManager.MoveMouseTo(Vector2.Zero); + + TimeToCompleteProgress = 2000; progressingNotifications.Clear(); - Content.Children = new Drawable[] + Children = new Drawable[] { notificationOverlay = new NotificationOverlay { @@ -40,13 +49,258 @@ namespace osu.Game.Tests.Visual.UserInterface displayedCount = new OsuSpriteText() }; - notificationOverlay.UnreadCount.ValueChanged += count => { displayedCount.Text = $"displayed count: {count.NewValue}"; }; + notificationOverlay.UnreadCount.ValueChanged += count => { displayedCount.Text = $"unread count: {count.NewValue}"; }; }); + [Test] + public void TestForwardWithFlingRight() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notification.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notification.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(500, 0)); + }); + + AddStep("fling away", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("was not closed", () => !notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddAssert("is not read", () => !notification.Read); + AddAssert("is not toast", () => !notification.IsInToastTray); + + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count one", () => notificationOverlay.UnreadCount.Value == 1); + } + + [Test] + public void TestDismissWithoutActivationFling() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(-500, 0)); + }); + + AddStep("fling away", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count zero", () => notificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestProgressNotificationCantBeFlung() + { + bool activated = false; + ProgressNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new ProgressNotification + { + Text = @"Uploading to BSS...", + CompletionText = "Uploaded to BSS!", + Activated = () => activated = true, + }); + + progressingNotifications.Add(notification); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(-500, 0)); + }); + + AddStep("attempt fling", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("was not closed", () => !notification.WasClosed); + AddUntilStep("was not cancelled", () => notification.State == ProgressNotificationState.Active); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + + AddUntilStep("was completed", () => notification.State == ProgressNotificationState.Completed); + } + + [Test] + public void TestDismissWithoutActivationCloseButton() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("click to activate", () => + { + InputManager.MoveMouseTo(notificationOverlay + .ChildrenOfType().Single() + .ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count zero", () => notificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestDismissWithoutActivationRightClick() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("click to activate", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + } + + [Test] + public void TestActivate() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("click to activate", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was activated", () => activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + } + + [Test] + public void TestPresence() + { + AddAssert("tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddAssert("overlay not present", () => !notificationOverlay.IsPresent); + + AddStep(@"post notification", sendBackgroundNotification); + + AddUntilStep("wait tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddUntilStep("wait overlay not present", () => !notificationOverlay.IsPresent); + } + + [Test] + public void TestPresenceWithManualDismiss() + { + AddAssert("tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddAssert("overlay not present", () => !notificationOverlay.IsPresent); + + AddStep(@"post notification", sendBackgroundNotification); + AddStep("click notification", () => notificationOverlay.ChildrenOfType().Single().TriggerClick()); + + AddUntilStep("wait tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddUntilStep("wait overlay not present", () => !notificationOverlay.IsPresent); + } + + [Test] + public void TestProgressClick() + { + ProgressNotification notification = null!; + + AddStep("add progress notification", () => + { + notification = new ProgressNotification + { + Text = @"Uploading to BSS...", + CompletionText = "Uploaded to BSS!", + }; + notificationOverlay.Post(notification); + progressingNotifications.Add(notification); + }); + + AddStep("hover over notification", () => InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single())); + + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddAssert("not cancelled", () => notification.State == ProgressNotificationState.Active); + + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddAssert("cancelled", () => notification.State == ProgressNotificationState.Cancelled); + } + [Test] public void TestCompleteProgress() { - ProgressNotification notification = null; + ProgressNotification notification = null!; + AddStep("add progress notification", () => { notification = new ProgressNotification @@ -59,12 +313,39 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("wait completion", () => notification.State == ProgressNotificationState.Completed); + + AddAssert("Completion toast shown", () => notificationOverlay.ToastCount == 1); + AddUntilStep("wait forwarded", () => notificationOverlay.ToastCount == 0); + } + + [Test] + public void TestCompleteProgressSlow() + { + ProgressNotification notification = null!; + + AddStep("Set progress slow", () => TimeToCompleteProgress *= 2); + AddStep("add progress notification", () => + { + notification = new ProgressNotification + { + Text = @"Uploading to BSS...", + CompletionText = "Uploaded to BSS!", + }; + notificationOverlay.Post(notification); + progressingNotifications.Add(notification); + }); + + AddUntilStep("wait completion", () => notification.State == ProgressNotificationState.Completed); + + AddAssert("Completion toast shown", () => notificationOverlay.ToastCount == 1); + AddUntilStep("wait forwarded", () => notificationOverlay.ToastCount == 0); + AddAssert("only one unread", () => notificationOverlay.UnreadCount.Value == 1); } [Test] public void TestCancelProgress() { - ProgressNotification notification = null; + ProgressNotification notification = null!; AddStep("add progress notification", () => { notification = new ProgressNotification @@ -81,6 +362,55 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("cancel notification", () => notification.State = ProgressNotificationState.Cancelled); } + [Test] + public void TestReadState() + { + SimpleNotification notification = null!; + AddStep(@"post", () => notificationOverlay.Post(notification = new BackgroundNotification { Text = @"Welcome to osu!. Enjoy your stay!" })); + AddUntilStep("check is toast", () => notification.IsInToastTray); + AddAssert("light is not visible", () => notification.ChildrenOfType().Single().Alpha == 0); + + AddUntilStep("wait for forward to overlay", () => !notification.IsInToastTray); + + setState(Visibility.Visible); + AddAssert("state is not read", () => !notification.Read); + AddUntilStep("light is visible", () => notification.ChildrenOfType().Single().Alpha == 1); + + setState(Visibility.Hidden); + setState(Visibility.Visible); + AddAssert("state is read", () => notification.Read); + AddUntilStep("light is not visible", () => notification.ChildrenOfType().Single().Alpha == 0); + } + + [Test] + public void TestUpdateNotificationFlow() + { + bool applyUpdate = false; + + AddStep(@"post update", () => + { + applyUpdate = false; + + var updateNotification = new UpdateManager.UpdateProgressNotification + { + CompletionClickAction = () => applyUpdate = true + }; + + notificationOverlay.Post(updateNotification); + progressingNotifications.Add(updateNotification); + }); + + checkProgressingCount(1); + waitForCompletion(); + + UpdateManager.UpdateApplicationCompleteNotification? completionNotification = null; + AddUntilStep("wait for completion notification", + () => (completionNotification = notificationOverlay.ChildrenOfType().SingleOrDefault()) != null); + AddStep("click notification", () => completionNotification?.TriggerClick()); + + AddUntilStep("wait for update applied", () => applyUpdate); + } + [Test] public void TestBasicFlow() { @@ -112,7 +442,8 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep(@"simple #1", sendHelloNotification); - AddAssert("Is visible", () => notificationOverlay.State.Value == Visibility.Visible); + AddAssert("toast displayed", () => notificationOverlay.ToastCount == 1); + AddAssert("is not visible", () => notificationOverlay.State.Value == Visibility.Hidden); checkDisplayedCount(1); @@ -161,11 +492,19 @@ namespace osu.Game.Tests.Visual.UserInterface AddRepeatStep("send barrage", sendBarrage, 10); } + [Test] + public void TestServerShuttingDownNotification() + { + AddStep("post with 5 seconds", () => notificationOverlay.Post(new ServerShutdownNotification(TimeSpan.FromSeconds(5)))); + AddStep("post with 30 seconds", () => notificationOverlay.Post(new ServerShutdownNotification(TimeSpan.FromSeconds(30)))); + AddStep("post with 6 hours", () => notificationOverlay.Post(new ServerShutdownNotification(TimeSpan.FromHours(6)))); + } + protected override void Update() { base.Update(); - progressingNotifications.RemoveAll(n => n.State == ProgressNotificationState.Completed); + progressingNotifications.RemoveAll(n => n.State == ProgressNotificationState.Completed && n.WasClosed); if (progressingNotifications.Count(n => n.State == ProgressNotificationState.Active) < 3) { @@ -178,14 +517,14 @@ namespace osu.Game.Tests.Visual.UserInterface foreach (var n in progressingNotifications.FindAll(n => n.State == ProgressNotificationState.Active)) { if (n.Progress < 1) - n.Progress += (float)(Time.Elapsed / 2000); + n.Progress += (float)(Time.Elapsed / TimeToCompleteProgress); else n.State = ProgressNotificationState.Completed; } } private void checkDisplayedCount(int expected) => - AddAssert($"Displayed count is {expected}", () => notificationOverlay.UnreadCount.Value == expected); + AddUntilStep($"Displayed count is {expected}", () => notificationOverlay.UnreadCount.Value == expected); private void sendDownloadProgress() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs index ee5ef2f364..d87bcfa5dd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs @@ -1,18 +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.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Bindables; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Database; -using osu.Game.Graphics.Containers; using osu.Game.Overlays.Music; +using osu.Game.Rulesets; using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; @@ -21,13 +22,23 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestScenePlaylistOverlay : OsuManualInputManagerTestScene { - private readonly BindableList> beatmapSets = new BindableList>(); + protected override bool UseFreshStoragePerRun => true; - private PlaylistOverlay playlistOverlay; + private PlaylistOverlay playlistOverlay = null!; - private Live first; + private BeatmapManager beatmapManager = null!; - private const int item_count = 100; + private const int item_count = 20; + + private List beatmapSets => beatmapManager.GetAllUsableBeatmapSets(); + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + } [SetUp] public void Setup() => Schedule(() => @@ -46,16 +57,15 @@ namespace osu.Game.Tests.Visual.UserInterface } }; - beatmapSets.Clear(); - for (int i = 0; i < item_count; i++) { - beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo().ToLiveUnmanaged()); + beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()); } - first = beatmapSets.First(); + beatmapSets.First().ToLive(Realm); - playlistOverlay.BeatmapSets.BindTo(beatmapSets); + // Ensure all the initial imports are present before running any tests. + Realm.Run(r => r.Refresh()); }); [Test] @@ -70,9 +80,13 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for animations to complete", () => !playlistOverlay.Transforms.Any()); + PlaylistItem firstItem = null!; + AddStep("hold 1st item handle", () => { - var handle = this.ChildrenOfType>.PlaylistItemHandle>().First(); + firstItem = this.ChildrenOfType().First(); + var handle = firstItem.ChildrenOfType().First(); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre); InputManager.PressButton(MouseButton.Left); }); @@ -83,7 +97,7 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.MoveMouseTo(item.ScreenSpaceDrawQuad.BottomLeft); }); - AddAssert("song 1 is 5th", () => beatmapSets[4].Equals(first)); + AddAssert("first is moved", () => playlistOverlay.ChildrenOfType().Single().Items.ElementAt(4).Value.Equals(firstItem.Model.Value)); AddStep("release handle", () => InputManager.ReleaseButton(MouseButton.Left)); } @@ -101,6 +115,68 @@ namespace osu.Game.Tests.Visual.UserInterface () => playlistOverlay.ChildrenOfType() .Where(item => item.MatchingFilter) .All(item => item.FilterTerms.Any(term => term.ToString().Contains("10")))); + + AddStep("Import new non-matching beatmap", () => + { + var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(1); + testBeatmapSetInfo.Beatmaps.Single().Metadata.Title = "no guid"; + beatmapManager.Import(testBeatmapSetInfo); + }); + + AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); + + AddAssert("results filtered correctly", + () => playlistOverlay.ChildrenOfType() + .Where(item => item.MatchingFilter) + .All(item => item.FilterTerms.Any(term => term.ToString().Contains("10")))); + } + + [Test] + public void TestCollectionFiltering() + { + NowPlayingCollectionDropdown collectionDropdown() => playlistOverlay.ChildrenOfType().Single(); + + AddStep("Add collection", () => + { + Realm.Write(r => + { + r.RemoveAll(); + r.Add(new BeatmapCollection("wang")); + }); + }); + + AddUntilStep("wait for dropdown to have new collection", () => collectionDropdown().Items.Count() == 2); + + AddStep("Filter to collection", () => + { + collectionDropdown().Current.Value = collectionDropdown().Items.Last(); + }); + + AddUntilStep("No items present", () => !playlistOverlay.ChildrenOfType().Any(i => i.MatchingFilter)); + + AddStep("Import new non-matching beatmap", () => + { + beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(1)); + }); + + AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); + + AddUntilStep("No items matching", () => !playlistOverlay.ChildrenOfType().Any(i => i.MatchingFilter)); + + BeatmapSetInfo collectionAddedBeatmapSet = null!; + + AddStep("Import new matching beatmap", () => + { + collectionAddedBeatmapSet = TestResources.CreateTestBeatmapSetInfo(1); + + beatmapManager.Import(collectionAddedBeatmapSet); + Realm.Write(r => r.All().First().BeatmapMD5Hashes.Add(collectionAddedBeatmapSet.Beatmaps.First().MD5Hash)); + }); + + AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); + + AddUntilStep("Only matching item", + () => playlistOverlay.ChildrenOfType().Where(i => i.MatchingFilter).Select(i => i.Model.ID), () => Is.EquivalentTo(new[] { collectionAddedBeatmapSet.ID })); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs index 50e506f82b..9fb0905a4f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs @@ -5,12 +5,14 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Overlays.Settings; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -20,11 +22,17 @@ namespace osu.Game.Tests.Visual.UserInterface { private SettingsToolboxGroup group; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void SetUp() => Schedule(() => { Child = group = new SettingsToolboxGroup("example") { + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Children = new Drawable[] { new RoundedButton diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs index ba9e1c6366..6c485aff34 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs @@ -3,9 +3,13 @@ #nullable disable +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK.Input; @@ -99,7 +103,10 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Text = "Fixed width" }); + AddAssert("draw width is 200", () => toggleButton.DrawWidth, () => Is.EqualTo(200).Within(Precision.FLOAT_EPSILON)); + AddStep("change text", () => toggleButton.Text = "New text"); + AddAssert("draw width is 200", () => toggleButton.DrawWidth, () => Is.EqualTo(200).Within(Precision.FLOAT_EPSILON)); AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton { @@ -107,7 +114,14 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Text = "This button autosizes to its text!" }); + AddAssert("button is wider than text", () => toggleButton.DrawWidth, () => Is.GreaterThan(toggleButton.ChildrenOfType().Single().DrawWidth)); + + float originalDrawWidth = 0; + AddStep("store button width", () => originalDrawWidth = toggleButton.DrawWidth); + AddStep("change text", () => toggleButton.Text = "New text"); + AddAssert("button is wider than text", () => toggleButton.DrawWidth, () => Is.GreaterThan(toggleButton.ChildrenOfType().Single().DrawWidth)); + AddAssert("button width decreased", () => toggleButton.DrawWidth, () => Is.LessThan(originalDrawWidth)); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs new file mode 100644 index 0000000000..c4568d9aeb --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.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; +using System.Globalization; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneSizePreservingSpriteText : OsuGridTestScene + { + private readonly List parentContainers = new List(); + private readonly List childContainers = new List(); + private readonly OsuSpriteText osuSpriteText = new OsuSpriteText(); + private readonly SizePreservingSpriteText sizePreservingSpriteText = new SizePreservingSpriteText(); + + public TestSceneSizePreservingSpriteText() + : base(1, 2) + { + for (int i = 0; i < 2; i++) + { + UprightAspectMaintainingContainer childContainer; + Container parentContainer = new Container + { + Origin = Anchor.BottomRight, + Anchor = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + Rotation = 45, + Y = -200, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Red, + }, + childContainer = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Blue, + }, + } + }, + } + }; + + Container cellInfo = new Container + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding + { + Top = 100, + }, + Child = new OsuSpriteText + { + Text = (i == 0) ? "OsuSpriteText" : "SizePreservingSpriteText", + Font = OsuFont.GetFont(Typeface.Inter, weight: FontWeight.Bold, size: 40), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + }; + + parentContainers.Add(parentContainer); + childContainers.Add(childContainer); + Cell(i).Add(cellInfo); + Cell(i).Add(parentContainer); + } + + childContainers[0].Add(osuSpriteText); + childContainers[1].Add(sizePreservingSpriteText); + osuSpriteText.Font = sizePreservingSpriteText.Font = OsuFont.GetFont(Typeface.Venera, weight: FontWeight.Bold, size: 20); + } + + protected override void Update() + { + base.Update(); + osuSpriteText.Text = sizePreservingSpriteText.Text = DateTime.Now.ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs index 1f65b6ec7f..72929a4555 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs @@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.UserInterface AutoSizeAxes = Axes.Both, Spacing = new Vector2(2f), Direction = FillDirection.Horizontal, - ChildrenEnumerable = Enumerable.Range(0, 15).Select(i => new FillFlowContainer + ChildrenEnumerable = Enumerable.Range(-1, 15).Select(i => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs new file mode 100644 index 0000000000..67c26829df --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs @@ -0,0 +1,244 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneUprightAspectMaintainingContainer : OsuGridTestScene + { + private const int rows = 3; + private const int columns = 4; + + private readonly ScaleMode[] scaleModeValues = { ScaleMode.NoScaling, ScaleMode.Horizontal, ScaleMode.Vertical }; + private readonly float[] scalingFactorValues = { 1.0f / 3, 1.0f / 2, 1.0f, 1.5f }; + + private readonly List> parentContainers = new List>(rows); + private readonly List> childContainers = new List>(rows); + + // Preferably should be set to (4 * 2^n) + private const int rotation_step_count = 3; + + private readonly List flipStates = new List(); + private readonly List rotationSteps = new List(); + private readonly List scaleSteps = new List(); + + public TestSceneUprightAspectMaintainingContainer() + : base(rows, columns) + { + for (int i = 0; i < rows; i++) + { + parentContainers.Add(new List()); + childContainers.Add(new List()); + + for (int j = 0; j < columns; j++) + { + UprightAspectMaintainingContainer child; + Container parent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 80, + Width = 80, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(255, 0, 0, 160), + }, + new OsuSpriteText + { + Text = "Parent", + }, + child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + + // These are the parameters being Tested + Scaling = scaleModeValues[i], + ScalingFactor = scalingFactorValues[j], + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 255, 160), + }, + new OsuSpriteText + { + Text = "Text", + Font = OsuFont.Numeric, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Padding = new MarginPadding + { + Horizontal = 4, + Vertical = 4, + } + }, + } + } + } + }; + + Container cellInfo = new Container + { + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Scaling: " + scaleModeValues[i].ToString(), + }, + new OsuSpriteText + { + Text = "ScalingFactor: " + scalingFactorValues[j].ToString("0.00"), + Margin = new MarginPadding + { + Top = 15, + }, + }, + }, + }; + + Cell(i * columns + j).Add(cellInfo); + Cell(i * columns + j).Add(parent); + parentContainers[i].Add(parent); + childContainers[i].Add(child); + } + } + + flipStates.AddRange(new[] { 1, -1 }); + rotationSteps.AddRange(Enumerable.Range(0, rotation_step_count).Select(x => 360f * ((float)x / rotation_step_count))); + scaleSteps.AddRange(new[] { 1, 0.3f, 1.5f }); + } + + [Test] + public void ExplicitlySizedParent() + { + var parentStates = from xFlip in flipStates + from yFlip in flipStates + from xScale in scaleSteps + from yScale in scaleSteps + from rotation in rotationSteps + select new { xFlip, yFlip, xScale, yScale, rotation }; + + foreach (var state in parentStates) + { + Vector2 parentScale = new Vector2(state.xFlip * state.xScale, state.yFlip * state.yScale); + float parentRotation = state.rotation; + + AddStep("S: (" + parentScale.X.ToString("0.00") + ", " + parentScale.Y.ToString("0.00") + "), R: " + parentRotation.ToString("0.00"), () => + { + foreach (List list in parentContainers) + { + foreach (Container container in list) + { + container.Scale = parentScale; + container.Rotation = parentRotation; + } + } + }); + + AddAssert("Check if state is valid", () => + { + foreach (int i in Enumerable.Range(0, parentContainers.Count)) + { + foreach (int j in Enumerable.Range(0, parentContainers[i].Count)) + { + if (!uprightAspectMaintainingContainerStateIsValid(parentContainers[i][j], childContainers[i][j])) + return false; + } + } + + return true; + }); + } + } + + private bool uprightAspectMaintainingContainerStateIsValid(Container parent, UprightAspectMaintainingContainer child) + { + Matrix3 parentMatrix = parent.DrawInfo.Matrix; + Matrix3 childMatrix = child.DrawInfo.Matrix; + Vector3 childScale = childMatrix.ExtractScale(); + Vector3 parentScale = parentMatrix.ExtractScale(); + + // Orientation check + if (!(isNearlyZero(MathF.Abs(childMatrix.M21)) && isNearlyZero(MathF.Abs(childMatrix.M12)))) + return false; + + // flip check + if (!(childMatrix.M11 * childMatrix.M22 > 0)) + return false; + + // Aspect ratio check + if (!isNearlyZero(childScale.X - childScale.Y)) + return false; + + // ScalingMode check + switch (child.Scaling) + { + case ScaleMode.NoScaling: + if (!(isNearlyZero(childMatrix.M11 - 1.0f) && isNearlyZero(childMatrix.M22 - 1.0f))) + return false; + + break; + + case ScaleMode.Vertical: + if (!(checkScaling(child.ScalingFactor, parentScale.Y, childScale.Y))) + return false; + + break; + + case ScaleMode.Horizontal: + if (!(checkScaling(child.ScalingFactor, parentScale.X, childScale.X))) + return false; + + break; + } + + return true; + } + + private bool checkScaling(float scalingFactor, float parentScale, float childScale) + { + if (scalingFactor <= 1.0f) + { + if (!isNearlyZero(1.0f + (parentScale - 1.0f) * scalingFactor - childScale)) + return false; + } + else if (scalingFactor > 1.0f) + { + if (parentScale < 1.0f) + { + if (!isNearlyZero((parentScale * (1.0f / scalingFactor)) - childScale)) + return false; + } + else if (!isNearlyZero(parentScale * scalingFactor - childScale)) + return false; + } + + return true; + } + + private bool isNearlyZero(float f, float epsilon = Precision.FLOAT_EPSILON) + { + return f < epsilon; + } + } +} diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index a1eef4ce47..bdf8cc5136 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,14 +1,12 @@  - - - + + - - - + + WinExe diff --git a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs index ad776622be..df77b31191 100644 --- a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs @@ -6,6 +6,7 @@ using System; using System.IO; using System.Threading.Tasks; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -27,7 +28,6 @@ namespace osu.Game.Tournament.Tests.NonVisual { var osu = new TestTournament(runOnLoadComplete: () => { - // ReSharper disable once AccessToDisposedClosure var storage = host.Storage.GetStorageForDirectory(Path.Combine("tournaments", "default")); using (var stream = storage.CreateFileSafely("bracket.json")) @@ -85,7 +85,7 @@ namespace osu.Game.Tournament.Tests.NonVisual public new Task BracketLoadTask => base.BracketLoadTask; - public TestTournament(bool resetRuleset = false, Action runOnLoadComplete = null) + public TestTournament(bool resetRuleset = false, [InstantHandle] Action runOnLoadComplete = null) { this.resetRuleset = resetRuleset; this.runOnLoadComplete = runOnLoadComplete; diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index 842324d03d..4fc15c365f 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tournament.Tests.Screens { AddStep("setup screen", () => { - Remove(chat); + Remove(chat, false); Children = new Drawable[] { diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 6fd53d923b..bdef46a6b2 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,10 +4,9 @@ osu.Game.Tournament.Tests.TournamentTestRunner - - + - + WinExe diff --git a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs index 0fc3646585..b088670caa 100644 --- a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs +++ b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs @@ -12,7 +12,8 @@ namespace osu.Game.Tournament.Components { public class TournamentSpriteTextWithBackground : CompositeDrawable { - protected readonly TournamentSpriteText Text; + public readonly TournamentSpriteText Text; + protected readonly Box Background; public TournamentSpriteTextWithBackground(string text = "") diff --git a/osu.Game.Tournament/Components/TourneyVideo.cs b/osu.Game.Tournament/Components/TourneyVideo.cs index c6bbb54f9a..2e79998e66 100644 --- a/osu.Game.Tournament/Components/TourneyVideo.cs +++ b/osu.Game.Tournament/Components/TourneyVideo.cs @@ -22,6 +22,8 @@ namespace osu.Game.Tournament.Components private Video video; private ManualClock manualClock; + public bool VideoAvailable => video != null; + public TourneyVideo(string filename, bool drawFallbackGradient = false) { this.filename = filename; diff --git a/osu.Game.Tournament/CountryExtensions.cs b/osu.Game.Tournament/CountryExtensions.cs new file mode 100644 index 0000000000..f2a583c8a5 --- /dev/null +++ b/osu.Game.Tournament/CountryExtensions.cs @@ -0,0 +1,770 @@ +// 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.Users; + +namespace osu.Game.Tournament +{ + public static class CountryExtensions + { + public static string GetAcronym(this CountryCode country) + { + switch (country) + { + case CountryCode.BD: + return "BGD"; + + case CountryCode.BE: + return "BEL"; + + case CountryCode.BF: + return "BFA"; + + case CountryCode.BG: + return "BGR"; + + case CountryCode.BA: + return "BIH"; + + case CountryCode.BB: + return "BRB"; + + case CountryCode.WF: + return "WLF"; + + case CountryCode.BL: + return "BLM"; + + case CountryCode.BM: + return "BMU"; + + case CountryCode.BN: + return "BRN"; + + case CountryCode.BO: + return "BOL"; + + case CountryCode.BH: + return "BHR"; + + case CountryCode.BI: + return "BDI"; + + case CountryCode.BJ: + return "BEN"; + + case CountryCode.BT: + return "BTN"; + + case CountryCode.JM: + return "JAM"; + + case CountryCode.BV: + return "BVT"; + + case CountryCode.BW: + return "BWA"; + + case CountryCode.WS: + return "WSM"; + + case CountryCode.BQ: + return "BES"; + + case CountryCode.BR: + return "BRA"; + + case CountryCode.BS: + return "BHS"; + + case CountryCode.JE: + return "JEY"; + + case CountryCode.BY: + return "BLR"; + + case CountryCode.BZ: + return "BLZ"; + + case CountryCode.RU: + return "RUS"; + + case CountryCode.RW: + return "RWA"; + + case CountryCode.RS: + return "SRB"; + + case CountryCode.TL: + return "TLS"; + + case CountryCode.RE: + return "REU"; + + case CountryCode.TM: + return "TKM"; + + case CountryCode.TJ: + return "TJK"; + + case CountryCode.RO: + return "ROU"; + + case CountryCode.TK: + return "TKL"; + + case CountryCode.GW: + return "GNB"; + + case CountryCode.GU: + return "GUM"; + + case CountryCode.GT: + return "GTM"; + + case CountryCode.GS: + return "SGS"; + + case CountryCode.GR: + return "GRC"; + + case CountryCode.GQ: + return "GNQ"; + + case CountryCode.GP: + return "GLP"; + + case CountryCode.JP: + return "JPN"; + + case CountryCode.GY: + return "GUY"; + + case CountryCode.GG: + return "GGY"; + + case CountryCode.GF: + return "GUF"; + + case CountryCode.GE: + return "GEO"; + + case CountryCode.GD: + return "GRD"; + + case CountryCode.GB: + return "GBR"; + + case CountryCode.GA: + return "GAB"; + + case CountryCode.SV: + return "SLV"; + + case CountryCode.GN: + return "GIN"; + + case CountryCode.GM: + return "GMB"; + + case CountryCode.GL: + return "GRL"; + + case CountryCode.GI: + return "GIB"; + + case CountryCode.GH: + return "GHA"; + + case CountryCode.OM: + return "OMN"; + + case CountryCode.TN: + return "TUN"; + + case CountryCode.JO: + return "JOR"; + + case CountryCode.HR: + return "HRV"; + + case CountryCode.HT: + return "HTI"; + + case CountryCode.HU: + return "HUN"; + + case CountryCode.HK: + return "HKG"; + + case CountryCode.HN: + return "HND"; + + case CountryCode.HM: + return "HMD"; + + case CountryCode.VE: + return "VEN"; + + case CountryCode.PR: + return "PRI"; + + case CountryCode.PS: + return "PSE"; + + case CountryCode.PW: + return "PLW"; + + case CountryCode.PT: + return "PRT"; + + case CountryCode.SJ: + return "SJM"; + + case CountryCode.PY: + return "PRY"; + + case CountryCode.IQ: + return "IRQ"; + + case CountryCode.PA: + return "PAN"; + + case CountryCode.PF: + return "PYF"; + + case CountryCode.PG: + return "PNG"; + + case CountryCode.PE: + return "PER"; + + case CountryCode.PK: + return "PAK"; + + case CountryCode.PH: + return "PHL"; + + case CountryCode.PN: + return "PCN"; + + case CountryCode.PL: + return "POL"; + + case CountryCode.PM: + return "SPM"; + + case CountryCode.ZM: + return "ZMB"; + + case CountryCode.EH: + return "ESH"; + + case CountryCode.EE: + return "EST"; + + case CountryCode.EG: + return "EGY"; + + case CountryCode.ZA: + return "ZAF"; + + case CountryCode.EC: + return "ECU"; + + case CountryCode.IT: + return "ITA"; + + case CountryCode.VN: + return "VNM"; + + case CountryCode.SB: + return "SLB"; + + case CountryCode.ET: + return "ETH"; + + case CountryCode.SO: + return "SOM"; + + case CountryCode.ZW: + return "ZWE"; + + case CountryCode.SA: + return "SAU"; + + case CountryCode.ES: + return "ESP"; + + case CountryCode.ER: + return "ERI"; + + case CountryCode.ME: + return "MNE"; + + case CountryCode.MD: + return "MDA"; + + case CountryCode.MG: + return "MDG"; + + case CountryCode.MF: + return "MAF"; + + case CountryCode.MA: + return "MAR"; + + case CountryCode.MC: + return "MCO"; + + case CountryCode.UZ: + return "UZB"; + + case CountryCode.MM: + return "MMR"; + + case CountryCode.ML: + return "MLI"; + + case CountryCode.MO: + return "MAC"; + + case CountryCode.MN: + return "MNG"; + + case CountryCode.MH: + return "MHL"; + + case CountryCode.MK: + return "MKD"; + + case CountryCode.MU: + return "MUS"; + + case CountryCode.MT: + return "MLT"; + + case CountryCode.MW: + return "MWI"; + + case CountryCode.MV: + return "MDV"; + + case CountryCode.MQ: + return "MTQ"; + + case CountryCode.MP: + return "MNP"; + + case CountryCode.MS: + return "MSR"; + + case CountryCode.MR: + return "MRT"; + + case CountryCode.IM: + return "IMN"; + + case CountryCode.UG: + return "UGA"; + + case CountryCode.TZ: + return "TZA"; + + case CountryCode.MY: + return "MYS"; + + case CountryCode.MX: + return "MEX"; + + case CountryCode.IL: + return "ISR"; + + case CountryCode.FR: + return "FRA"; + + case CountryCode.IO: + return "IOT"; + + case CountryCode.SH: + return "SHN"; + + case CountryCode.FI: + return "FIN"; + + case CountryCode.FJ: + return "FJI"; + + case CountryCode.FK: + return "FLK"; + + case CountryCode.FM: + return "FSM"; + + case CountryCode.FO: + return "FRO"; + + case CountryCode.NI: + return "NIC"; + + case CountryCode.NL: + return "NLD"; + + case CountryCode.NO: + return "NOR"; + + case CountryCode.NA: + return "NAM"; + + case CountryCode.VU: + return "VUT"; + + case CountryCode.NC: + return "NCL"; + + case CountryCode.NE: + return "NER"; + + case CountryCode.NF: + return "NFK"; + + case CountryCode.NG: + return "NGA"; + + case CountryCode.NZ: + return "NZL"; + + case CountryCode.NP: + return "NPL"; + + case CountryCode.NR: + return "NRU"; + + case CountryCode.NU: + return "NIU"; + + case CountryCode.CK: + return "COK"; + + case CountryCode.XK: + return "XKX"; + + case CountryCode.CI: + return "CIV"; + + case CountryCode.CH: + return "CHE"; + + case CountryCode.CO: + return "COL"; + + case CountryCode.CN: + return "CHN"; + + case CountryCode.CM: + return "CMR"; + + case CountryCode.CL: + return "CHL"; + + case CountryCode.CC: + return "CCK"; + + case CountryCode.CA: + return "CAN"; + + case CountryCode.CG: + return "COG"; + + case CountryCode.CF: + return "CAF"; + + case CountryCode.CD: + return "COD"; + + case CountryCode.CZ: + return "CZE"; + + case CountryCode.CY: + return "CYP"; + + case CountryCode.CX: + return "CXR"; + + case CountryCode.CR: + return "CRI"; + + case CountryCode.CW: + return "CUW"; + + case CountryCode.CV: + return "CPV"; + + case CountryCode.CU: + return "CUB"; + + case CountryCode.SZ: + return "SWZ"; + + case CountryCode.SY: + return "SYR"; + + case CountryCode.SX: + return "SXM"; + + case CountryCode.KG: + return "KGZ"; + + case CountryCode.KE: + return "KEN"; + + case CountryCode.SS: + return "SSD"; + + case CountryCode.SR: + return "SUR"; + + case CountryCode.KI: + return "KIR"; + + case CountryCode.KH: + return "KHM"; + + case CountryCode.KN: + return "KNA"; + + case CountryCode.KM: + return "COM"; + + case CountryCode.ST: + return "STP"; + + case CountryCode.SK: + return "SVK"; + + case CountryCode.KR: + return "KOR"; + + case CountryCode.SI: + return "SVN"; + + case CountryCode.KP: + return "PRK"; + + case CountryCode.KW: + return "KWT"; + + case CountryCode.SN: + return "SEN"; + + case CountryCode.SM: + return "SMR"; + + case CountryCode.SL: + return "SLE"; + + case CountryCode.SC: + return "SYC"; + + case CountryCode.KZ: + return "KAZ"; + + case CountryCode.KY: + return "CYM"; + + case CountryCode.SG: + return "SGP"; + + case CountryCode.SE: + return "SWE"; + + case CountryCode.SD: + return "SDN"; + + case CountryCode.DO: + return "DOM"; + + case CountryCode.DM: + return "DMA"; + + case CountryCode.DJ: + return "DJI"; + + case CountryCode.DK: + return "DNK"; + + case CountryCode.VG: + return "VGB"; + + case CountryCode.DE: + return "DEU"; + + case CountryCode.YE: + return "YEM"; + + case CountryCode.DZ: + return "DZA"; + + case CountryCode.US: + return "USA"; + + case CountryCode.UY: + return "URY"; + + case CountryCode.YT: + return "MYT"; + + case CountryCode.UM: + return "UMI"; + + case CountryCode.LB: + return "LBN"; + + case CountryCode.LC: + return "LCA"; + + case CountryCode.LA: + return "LAO"; + + case CountryCode.TV: + return "TUV"; + + case CountryCode.TW: + return "TWN"; + + case CountryCode.TT: + return "TTO"; + + case CountryCode.TR: + return "TUR"; + + case CountryCode.LK: + return "LKA"; + + case CountryCode.LI: + return "LIE"; + + case CountryCode.LV: + return "LVA"; + + case CountryCode.TO: + return "TON"; + + case CountryCode.LT: + return "LTU"; + + case CountryCode.LU: + return "LUX"; + + case CountryCode.LR: + return "LBR"; + + case CountryCode.LS: + return "LSO"; + + case CountryCode.TH: + return "THA"; + + case CountryCode.TF: + return "ATF"; + + case CountryCode.TG: + return "TGO"; + + case CountryCode.TD: + return "TCD"; + + case CountryCode.TC: + return "TCA"; + + case CountryCode.LY: + return "LBY"; + + case CountryCode.VA: + return "VAT"; + + case CountryCode.VC: + return "VCT"; + + case CountryCode.AE: + return "ARE"; + + case CountryCode.AD: + return "AND"; + + case CountryCode.AG: + return "ATG"; + + case CountryCode.AF: + return "AFG"; + + case CountryCode.AI: + return "AIA"; + + case CountryCode.VI: + return "VIR"; + + case CountryCode.IS: + return "ISL"; + + case CountryCode.IR: + return "IRN"; + + case CountryCode.AM: + return "ARM"; + + case CountryCode.AL: + return "ALB"; + + case CountryCode.AO: + return "AGO"; + + case CountryCode.AQ: + return "ATA"; + + case CountryCode.AS: + return "ASM"; + + case CountryCode.AR: + return "ARG"; + + case CountryCode.AU: + return "AUS"; + + case CountryCode.AT: + return "AUT"; + + case CountryCode.AW: + return "ABW"; + + case CountryCode.IN: + return "IND"; + + case CountryCode.AX: + return "ALA"; + + case CountryCode.AZ: + return "AZE"; + + case CountryCode.IE: + return "IRL"; + + case CountryCode.ID: + return "IDN"; + + case CountryCode.UA: + return "UKR"; + + case CountryCode.QA: + return "QAT"; + + case CountryCode.MZ: + return "MOZ"; + + default: + throw new ArgumentOutOfRangeException(nameof(country)); + } + } + } +} diff --git a/osu.Game.Tournament/JsonPointConverter.cs b/osu.Game.Tournament/JsonPointConverter.cs index db48c36c99..d3b40a3526 100644 --- a/osu.Game.Tournament/JsonPointConverter.cs +++ b/osu.Game.Tournament/JsonPointConverter.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics; using System.Drawing; +using System.Globalization; using Newtonsoft.Json; namespace osu.Game.Tournament @@ -31,7 +32,9 @@ namespace osu.Game.Tournament Debug.Assert(str != null); - return new PointConverter().ConvertFromString(str) as Point? ?? new Point(); + // Null check suppression is required due to .NET standard expecting a non-null context. + // Seems to work fine at a runtime level (and the parameter is nullable in .NET 6+). + return new PointConverter().ConvertFromString(null!, CultureInfo.InvariantCulture, str) as Point? ?? new Point(); } var point = new Point(); diff --git a/osu.Game.Tournament/Models/LadderInfo.cs b/osu.Game.Tournament/Models/LadderInfo.cs index 0f73b60774..6b64a1156e 100644 --- a/osu.Game.Tournament/Models/LadderInfo.cs +++ b/osu.Game.Tournament/Models/LadderInfo.cs @@ -40,5 +40,7 @@ namespace osu.Game.Tournament.Models MinValue = 3, MaxValue = 4, }; + + public Bindable AutoProgressScreens = new BindableBool(true); } } diff --git a/osu.Game.Tournament/Models/SeedingBeatmap.cs b/osu.Game.Tournament/Models/SeedingBeatmap.cs index 03beb7ca9a..fb0e20556c 100644 --- a/osu.Game.Tournament/Models/SeedingBeatmap.cs +++ b/osu.Game.Tournament/Models/SeedingBeatmap.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 Newtonsoft.Json; using osu.Framework.Bindables; @@ -13,7 +11,7 @@ namespace osu.Game.Tournament.Models public int ID; [JsonProperty("BeatmapInfo")] - public TournamentBeatmap Beatmap; + public TournamentBeatmap? Beatmap; public long Score; diff --git a/osu.Game.Tournament/Models/TournamentBeatmap.cs b/osu.Game.Tournament/Models/TournamentBeatmap.cs index 274fddc490..7f57b6a151 100644 --- a/osu.Game.Tournament/Models/TournamentBeatmap.cs +++ b/osu.Game.Tournament/Models/TournamentBeatmap.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; @@ -41,7 +40,7 @@ namespace osu.Game.Tournament.Models StarRating = beatmap.StarRating; Metadata = beatmap.Metadata; Difficulty = beatmap.Difficulty; - Covers = beatmap.BeatmapSet.AsNonNull().Covers; + Covers = beatmap.BeatmapSet?.Covers ?? new BeatmapSetOnlineCovers(); } public bool Equals(IBeatmapInfo? other) => other is TournamentBeatmap b && this.MatchesOnlineID(b); diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 2f72dc9257..97c2060f2c 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -106,13 +106,16 @@ namespace osu.Game.Tournament.Models } /// - /// Initialise this match with zeroed scores. Will be a noop if either team is not present. + /// Initialise this match with zeroed scores. Will be a noop if either team is not present or if either of the scores are non-zero. /// public void StartMatch() { if (Team1.Value == null || Team2.Value == null) return; + if (Team1Score.Value > 0 || Team2Score.Value > 0) + return; + Team1Score.Value = 0; Team2Score.Value = 0; } diff --git a/osu.Game.Tournament/Models/TournamentUser.cs b/osu.Game.Tournament/Models/TournamentUser.cs index 80e58538e5..78ca014860 100644 --- a/osu.Game.Tournament/Models/TournamentUser.cs +++ b/osu.Game.Tournament/Models/TournamentUser.cs @@ -22,7 +22,8 @@ namespace osu.Game.Tournament.Models /// /// The player's country. /// - public Country? Country { get; set; } + [JsonProperty("country_code")] + public CountryCode CountryCode { get; set; } /// /// The player's global rank, or null if not available. @@ -40,7 +41,7 @@ namespace osu.Game.Tournament.Models { Id = OnlineID, Username = Username, - Country = Country, + CountryCode = CountryCode, CoverUrl = CoverUrl, }; diff --git a/osu.Game.Tournament/Resources/countries.json b/osu.Game.Tournament/Resources/countries.json deleted file mode 100644 index 7306a8bec5..0000000000 --- a/osu.Game.Tournament/Resources/countries.json +++ /dev/null @@ -1,1252 +0,0 @@ -[ - { - "FlagName": "BD", - "FullName": "Bangladesh", - "Acronym": "BGD" - }, - { - "FlagName": "BE", - "FullName": "Belgium", - "Acronym": "BEL" - }, - { - "FlagName": "BF", - "FullName": "Burkina Faso", - "Acronym": "BFA" - }, - { - "FlagName": "BG", - "FullName": "Bulgaria", - "Acronym": "BGR" - }, - { - "FlagName": "BA", - "FullName": "Bosnia and Herzegovina", - "Acronym": "BIH" - }, - { - "FlagName": "BB", - "FullName": "Barbados", - "Acronym": "BRB" - }, - { - "FlagName": "WF", - "FullName": "Wallis and Futuna", - "Acronym": "WLF" - }, - { - "FlagName": "BL", - "FullName": "Saint Barthelemy", - "Acronym": "BLM" - }, - { - "FlagName": "BM", - "FullName": "Bermuda", - "Acronym": "BMU" - }, - { - "FlagName": "BN", - "FullName": "Brunei", - "Acronym": "BRN" - }, - { - "FlagName": "BO", - "FullName": "Bolivia", - "Acronym": "BOL" - }, - { - "FlagName": "BH", - "FullName": "Bahrain", - "Acronym": "BHR" - }, - { - "FlagName": "BI", - "FullName": "Burundi", - "Acronym": "BDI" - }, - { - "FlagName": "BJ", - "FullName": "Benin", - "Acronym": "BEN" - }, - { - "FlagName": "BT", - "FullName": "Bhutan", - "Acronym": "BTN" - }, - { - "FlagName": "JM", - "FullName": "Jamaica", - "Acronym": "JAM" - }, - { - "FlagName": "BV", - "FullName": "Bouvet Island", - "Acronym": "BVT" - }, - { - "FlagName": "BW", - "FullName": "Botswana", - "Acronym": "BWA" - }, - { - "FlagName": "WS", - "FullName": "Samoa", - "Acronym": "WSM" - }, - { - "FlagName": "BQ", - "FullName": "Bonaire, Saint Eustatius and Saba", - "Acronym": "BES" - }, - { - "FlagName": "BR", - "FullName": "Brazil", - "Acronym": "BRA" - }, - { - "FlagName": "BS", - "FullName": "Bahamas", - "Acronym": "BHS" - }, - { - "FlagName": "JE", - "FullName": "Jersey", - "Acronym": "JEY" - }, - { - "FlagName": "BY", - "FullName": "Belarus", - "Acronym": "BLR" - }, - { - "FlagName": "BZ", - "FullName": "Belize", - "Acronym": "BLZ" - }, - { - "FlagName": "RU", - "FullName": "Russia", - "Acronym": "RUS" - }, - { - "FlagName": "RW", - "FullName": "Rwanda", - "Acronym": "RWA" - }, - { - "FlagName": "RS", - "FullName": "Serbia", - "Acronym": "SRB" - }, - { - "FlagName": "TL", - "FullName": "East Timor", - "Acronym": "TLS" - }, - { - "FlagName": "RE", - "FullName": "Reunion", - "Acronym": "REU" - }, - { - "FlagName": "TM", - "FullName": "Turkmenistan", - "Acronym": "TKM" - }, - { - "FlagName": "TJ", - "FullName": "Tajikistan", - "Acronym": "TJK" - }, - { - "FlagName": "RO", - "FullName": "Romania", - "Acronym": "ROU" - }, - { - "FlagName": "TK", - "FullName": "Tokelau", - "Acronym": "TKL" - }, - { - "FlagName": "GW", - "FullName": "Guinea-Bissau", - "Acronym": "GNB" - }, - { - "FlagName": "GU", - "FullName": "Guam", - "Acronym": "GUM" - }, - { - "FlagName": "GT", - "FullName": "Guatemala", - "Acronym": "GTM" - }, - { - "FlagName": "GS", - "FullName": "South Georgia and the South Sandwich Islands", - "Acronym": "SGS" - }, - { - "FlagName": "GR", - "FullName": "Greece", - "Acronym": "GRC" - }, - { - "FlagName": "GQ", - "FullName": "Equatorial Guinea", - "Acronym": "GNQ" - }, - { - "FlagName": "GP", - "FullName": "Guadeloupe", - "Acronym": "GLP" - }, - { - "FlagName": "JP", - "FullName": "Japan", - "Acronym": "JPN" - }, - { - "FlagName": "GY", - "FullName": "Guyana", - "Acronym": "GUY" - }, - { - "FlagName": "GG", - "FullName": "Guernsey", - "Acronym": "GGY" - }, - { - "FlagName": "GF", - "FullName": "French Guiana", - "Acronym": "GUF" - }, - { - "FlagName": "GE", - "FullName": "Georgia", - "Acronym": "GEO" - }, - { - "FlagName": "GD", - "FullName": "Grenada", - "Acronym": "GRD" - }, - { - "FlagName": "GB", - "FullName": "United Kingdom", - "Acronym": "GBR" - }, - { - "FlagName": "GA", - "FullName": "Gabon", - "Acronym": "GAB" - }, - { - "FlagName": "SV", - "FullName": "El Salvador", - "Acronym": "SLV" - }, - { - "FlagName": "GN", - "FullName": "Guinea", - "Acronym": "GIN" - }, - { - "FlagName": "GM", - "FullName": "Gambia", - "Acronym": "GMB" - }, - { - "FlagName": "GL", - "FullName": "Greenland", - "Acronym": "GRL" - }, - { - "FlagName": "GI", - "FullName": "Gibraltar", - "Acronym": "GIB" - }, - { - "FlagName": "GH", - "FullName": "Ghana", - "Acronym": "GHA" - }, - { - "FlagName": "OM", - "FullName": "Oman", - "Acronym": "OMN" - }, - { - "FlagName": "TN", - "FullName": "Tunisia", - "Acronym": "TUN" - }, - { - "FlagName": "JO", - "FullName": "Jordan", - "Acronym": "JOR" - }, - { - "FlagName": "HR", - "FullName": "Croatia", - "Acronym": "HRV" - }, - { - "FlagName": "HT", - "FullName": "Haiti", - "Acronym": "HTI" - }, - { - "FlagName": "HU", - "FullName": "Hungary", - "Acronym": "HUN" - }, - { - "FlagName": "HK", - "FullName": "Hong Kong", - "Acronym": "HKG" - }, - { - "FlagName": "HN", - "FullName": "Honduras", - "Acronym": "HND" - }, - { - "FlagName": "HM", - "FullName": "Heard Island and McDonald Islands", - "Acronym": "HMD" - }, - { - "FlagName": "VE", - "FullName": "Venezuela", - "Acronym": "VEN" - }, - { - "FlagName": "PR", - "FullName": "Puerto Rico", - "Acronym": "PRI" - }, - { - "FlagName": "PS", - "FullName": "Palestinian Territory", - "Acronym": "PSE" - }, - { - "FlagName": "PW", - "FullName": "Palau", - "Acronym": "PLW" - }, - { - "FlagName": "PT", - "FullName": "Portugal", - "Acronym": "PRT" - }, - { - "FlagName": "SJ", - "FullName": "Svalbard and Jan Mayen", - "Acronym": "SJM" - }, - { - "FlagName": "PY", - "FullName": "Paraguay", - "Acronym": "PRY" - }, - { - "FlagName": "IQ", - "FullName": "Iraq", - "Acronym": "IRQ" - }, - { - "FlagName": "PA", - "FullName": "Panama", - "Acronym": "PAN" - }, - { - "FlagName": "PF", - "FullName": "French Polynesia", - "Acronym": "PYF" - }, - { - "FlagName": "PG", - "FullName": "Papua New Guinea", - "Acronym": "PNG" - }, - { - "FlagName": "PE", - "FullName": "Peru", - "Acronym": "PER" - }, - { - "FlagName": "PK", - "FullName": "Pakistan", - "Acronym": "PAK" - }, - { - "FlagName": "PH", - "FullName": "Philippines", - "Acronym": "PHL" - }, - { - "FlagName": "PN", - "FullName": "Pitcairn", - "Acronym": "PCN" - }, - { - "FlagName": "PL", - "FullName": "Poland", - "Acronym": "POL" - }, - { - "FlagName": "PM", - "FullName": "Saint Pierre and Miquelon", - "Acronym": "SPM" - }, - { - "FlagName": "ZM", - "FullName": "Zambia", - "Acronym": "ZMB" - }, - { - "FlagName": "EH", - "FullName": "Western Sahara", - "Acronym": "ESH" - }, - { - "FlagName": "EE", - "FullName": "Estonia", - "Acronym": "EST" - }, - { - "FlagName": "EG", - "FullName": "Egypt", - "Acronym": "EGY" - }, - { - "FlagName": "ZA", - "FullName": "South Africa", - "Acronym": "ZAF" - }, - { - "FlagName": "EC", - "FullName": "Ecuador", - "Acronym": "ECU" - }, - { - "FlagName": "IT", - "FullName": "Italy", - "Acronym": "ITA" - }, - { - "FlagName": "VN", - "FullName": "Vietnam", - "Acronym": "VNM" - }, - { - "FlagName": "SB", - "FullName": "Solomon Islands", - "Acronym": "SLB" - }, - { - "FlagName": "ET", - "FullName": "Ethiopia", - "Acronym": "ETH" - }, - { - "FlagName": "SO", - "FullName": "Somalia", - "Acronym": "SOM" - }, - { - "FlagName": "ZW", - "FullName": "Zimbabwe", - "Acronym": "ZWE" - }, - { - "FlagName": "SA", - "FullName": "Saudi Arabia", - "Acronym": "SAU" - }, - { - "FlagName": "ES", - "FullName": "Spain", - "Acronym": "ESP" - }, - { - "FlagName": "ER", - "FullName": "Eritrea", - "Acronym": "ERI" - }, - { - "FlagName": "ME", - "FullName": "Montenegro", - "Acronym": "MNE" - }, - { - "FlagName": "MD", - "FullName": "Moldova", - "Acronym": "MDA" - }, - { - "FlagName": "MG", - "FullName": "Madagascar", - "Acronym": "MDG" - }, - { - "FlagName": "MF", - "FullName": "Saint Martin", - "Acronym": "MAF" - }, - { - "FlagName": "MA", - "FullName": "Morocco", - "Acronym": "MAR" - }, - { - "FlagName": "MC", - "FullName": "Monaco", - "Acronym": "MCO" - }, - { - "FlagName": "UZ", - "FullName": "Uzbekistan", - "Acronym": "UZB" - }, - { - "FlagName": "MM", - "FullName": "Myanmar", - "Acronym": "MMR" - }, - { - "FlagName": "ML", - "FullName": "Mali", - "Acronym": "MLI" - }, - { - "FlagName": "MO", - "FullName": "Macao", - "Acronym": "MAC" - }, - { - "FlagName": "MN", - "FullName": "Mongolia", - "Acronym": "MNG" - }, - { - "FlagName": "MH", - "FullName": "Marshall Islands", - "Acronym": "MHL" - }, - { - "FlagName": "MK", - "FullName": "North Macedonia", - "Acronym": "MKD" - }, - { - "FlagName": "MU", - "FullName": "Mauritius", - "Acronym": "MUS" - }, - { - "FlagName": "MT", - "FullName": "Malta", - "Acronym": "MLT" - }, - { - "FlagName": "MW", - "FullName": "Malawi", - "Acronym": "MWI" - }, - { - "FlagName": "MV", - "FullName": "Maldives", - "Acronym": "MDV" - }, - { - "FlagName": "MQ", - "FullName": "Martinique", - "Acronym": "MTQ" - }, - { - "FlagName": "MP", - "FullName": "Northern Mariana Islands", - "Acronym": "MNP" - }, - { - "FlagName": "MS", - "FullName": "Montserrat", - "Acronym": "MSR" - }, - { - "FlagName": "MR", - "FullName": "Mauritania", - "Acronym": "MRT" - }, - { - "FlagName": "IM", - "FullName": "Isle of Man", - "Acronym": "IMN" - }, - { - "FlagName": "UG", - "FullName": "Uganda", - "Acronym": "UGA" - }, - { - "FlagName": "TZ", - "FullName": "Tanzania", - "Acronym": "TZA" - }, - { - "FlagName": "MY", - "FullName": "Malaysia", - "Acronym": "MYS" - }, - { - "FlagName": "MX", - "FullName": "Mexico", - "Acronym": "MEX" - }, - { - "FlagName": "IL", - "FullName": "Israel", - "Acronym": "ISR" - }, - { - "FlagName": "FR", - "FullName": "France", - "Acronym": "FRA" - }, - { - "FlagName": "IO", - "FullName": "British Indian Ocean Territory", - "Acronym": "IOT" - }, - { - "FlagName": "SH", - "FullName": "Saint Helena", - "Acronym": "SHN" - }, - { - "FlagName": "FI", - "FullName": "Finland", - "Acronym": "FIN" - }, - { - "FlagName": "FJ", - "FullName": "Fiji", - "Acronym": "FJI" - }, - { - "FlagName": "FK", - "FullName": "Falkland Islands", - "Acronym": "FLK" - }, - { - "FlagName": "FM", - "FullName": "Micronesia", - "Acronym": "FSM" - }, - { - "FlagName": "FO", - "FullName": "Faroe Islands", - "Acronym": "FRO" - }, - { - "FlagName": "NI", - "FullName": "Nicaragua", - "Acronym": "NIC" - }, - { - "FlagName": "NL", - "FullName": "Netherlands", - "Acronym": "NLD" - }, - { - "FlagName": "NO", - "FullName": "Norway", - "Acronym": "NOR" - }, - { - "FlagName": "NA", - "FullName": "Namibia", - "Acronym": "NAM" - }, - { - "FlagName": "VU", - "FullName": "Vanuatu", - "Acronym": "VUT" - }, - { - "FlagName": "NC", - "FullName": "New Caledonia", - "Acronym": "NCL" - }, - { - "FlagName": "NE", - "FullName": "Niger", - "Acronym": "NER" - }, - { - "FlagName": "NF", - "FullName": "Norfolk Island", - "Acronym": "NFK" - }, - { - "FlagName": "NG", - "FullName": "Nigeria", - "Acronym": "NGA" - }, - { - "FlagName": "NZ", - "FullName": "New Zealand", - "Acronym": "NZL" - }, - { - "FlagName": "NP", - "FullName": "Nepal", - "Acronym": "NPL" - }, - { - "FlagName": "NR", - "FullName": "Nauru", - "Acronym": "NRU" - }, - { - "FlagName": "NU", - "FullName": "Niue", - "Acronym": "NIU" - }, - { - "FlagName": "CK", - "FullName": "Cook Islands", - "Acronym": "COK" - }, - { - "FlagName": "XK", - "FullName": "Kosovo", - "Acronym": "XKX" - }, - { - "FlagName": "CI", - "FullName": "Ivory Coast", - "Acronym": "CIV" - }, - { - "FlagName": "CH", - "FullName": "Switzerland", - "Acronym": "CHE" - }, - { - "FlagName": "CO", - "FullName": "Colombia", - "Acronym": "COL" - }, - { - "FlagName": "CN", - "FullName": "China", - "Acronym": "CHN" - }, - { - "FlagName": "CM", - "FullName": "Cameroon", - "Acronym": "CMR" - }, - { - "FlagName": "CL", - "FullName": "Chile", - "Acronym": "CHL" - }, - { - "FlagName": "CC", - "FullName": "Cocos Islands", - "Acronym": "CCK" - }, - { - "FlagName": "CA", - "FullName": "Canada", - "Acronym": "CAN" - }, - { - "FlagName": "CG", - "FullName": "Republic of the Congo", - "Acronym": "COG" - }, - { - "FlagName": "CF", - "FullName": "Central African Republic", - "Acronym": "CAF" - }, - { - "FlagName": "CD", - "FullName": "Democratic Republic of the Congo", - "Acronym": "COD" - }, - { - "FlagName": "CZ", - "FullName": "Czech Republic", - "Acronym": "CZE" - }, - { - "FlagName": "CY", - "FullName": "Cyprus", - "Acronym": "CYP" - }, - { - "FlagName": "CX", - "FullName": "Christmas Island", - "Acronym": "CXR" - }, - { - "FlagName": "CR", - "FullName": "Costa Rica", - "Acronym": "CRI" - }, - { - "FlagName": "CW", - "FullName": "Curacao", - "Acronym": "CUW" - }, - { - "FlagName": "CV", - "FullName": "Cabo Verde", - "Acronym": "CPV" - }, - { - "FlagName": "CU", - "FullName": "Cuba", - "Acronym": "CUB" - }, - { - "FlagName": "SZ", - "FullName": "Eswatini", - "Acronym": "SWZ" - }, - { - "FlagName": "SY", - "FullName": "Syria", - "Acronym": "SYR" - }, - { - "FlagName": "SX", - "FullName": "Sint Maarten", - "Acronym": "SXM" - }, - { - "FlagName": "KG", - "FullName": "Kyrgyzstan", - "Acronym": "KGZ" - }, - { - "FlagName": "KE", - "FullName": "Kenya", - "Acronym": "KEN" - }, - { - "FlagName": "SS", - "FullName": "South Sudan", - "Acronym": "SSD" - }, - { - "FlagName": "SR", - "FullName": "Suriname", - "Acronym": "SUR" - }, - { - "FlagName": "KI", - "FullName": "Kiribati", - "Acronym": "KIR" - }, - { - "FlagName": "KH", - "FullName": "Cambodia", - "Acronym": "KHM" - }, - { - "FlagName": "KN", - "FullName": "Saint Kitts and Nevis", - "Acronym": "KNA" - }, - { - "FlagName": "KM", - "FullName": "Comoros", - "Acronym": "COM" - }, - { - "FlagName": "ST", - "FullName": "Sao Tome and Principe", - "Acronym": "STP" - }, - { - "FlagName": "SK", - "FullName": "Slovakia", - "Acronym": "SVK" - }, - { - "FlagName": "KR", - "FullName": "South Korea", - "Acronym": "KOR" - }, - { - "FlagName": "SI", - "FullName": "Slovenia", - "Acronym": "SVN" - }, - { - "FlagName": "KP", - "FullName": "North Korea", - "Acronym": "PRK" - }, - { - "FlagName": "KW", - "FullName": "Kuwait", - "Acronym": "KWT" - }, - { - "FlagName": "SN", - "FullName": "Senegal", - "Acronym": "SEN" - }, - { - "FlagName": "SM", - "FullName": "San Marino", - "Acronym": "SMR" - }, - { - "FlagName": "SL", - "FullName": "Sierra Leone", - "Acronym": "SLE" - }, - { - "FlagName": "SC", - "FullName": "Seychelles", - "Acronym": "SYC" - }, - { - "FlagName": "KZ", - "FullName": "Kazakhstan", - "Acronym": "KAZ" - }, - { - "FlagName": "KY", - "FullName": "Cayman Islands", - "Acronym": "CYM" - }, - { - "FlagName": "SG", - "FullName": "Singapore", - "Acronym": "SGP" - }, - { - "FlagName": "SE", - "FullName": "Sweden", - "Acronym": "SWE" - }, - { - "FlagName": "SD", - "FullName": "Sudan", - "Acronym": "SDN" - }, - { - "FlagName": "DO", - "FullName": "Dominican Republic", - "Acronym": "DOM" - }, - { - "FlagName": "DM", - "FullName": "Dominica", - "Acronym": "DMA" - }, - { - "FlagName": "DJ", - "FullName": "Djibouti", - "Acronym": "DJI" - }, - { - "FlagName": "DK", - "FullName": "Denmark", - "Acronym": "DNK" - }, - { - "FlagName": "VG", - "FullName": "British Virgin Islands", - "Acronym": "VGB" - }, - { - "FlagName": "DE", - "FullName": "Germany", - "Acronym": "DEU" - }, - { - "FlagName": "YE", - "FullName": "Yemen", - "Acronym": "YEM" - }, - { - "FlagName": "DZ", - "FullName": "Algeria", - "Acronym": "DZA" - }, - { - "FlagName": "US", - "FullName": "United States", - "Acronym": "USA" - }, - { - "FlagName": "UY", - "FullName": "Uruguay", - "Acronym": "URY" - }, - { - "FlagName": "YT", - "FullName": "Mayotte", - "Acronym": "MYT" - }, - { - "FlagName": "UM", - "FullName": "United States Minor Outlying Islands", - "Acronym": "UMI" - }, - { - "FlagName": "LB", - "FullName": "Lebanon", - "Acronym": "LBN" - }, - { - "FlagName": "LC", - "FullName": "Saint Lucia", - "Acronym": "LCA" - }, - { - "FlagName": "LA", - "FullName": "Laos", - "Acronym": "LAO" - }, - { - "FlagName": "TV", - "FullName": "Tuvalu", - "Acronym": "TUV" - }, - { - "FlagName": "TW", - "FullName": "Taiwan", - "Acronym": "TWN" - }, - { - "FlagName": "TT", - "FullName": "Trinidad and Tobago", - "Acronym": "TTO" - }, - { - "FlagName": "TR", - "FullName": "Turkey", - "Acronym": "TUR" - }, - { - "FlagName": "LK", - "FullName": "Sri Lanka", - "Acronym": "LKA" - }, - { - "FlagName": "LI", - "FullName": "Liechtenstein", - "Acronym": "LIE" - }, - { - "FlagName": "LV", - "FullName": "Latvia", - "Acronym": "LVA" - }, - { - "FlagName": "TO", - "FullName": "Tonga", - "Acronym": "TON" - }, - { - "FlagName": "LT", - "FullName": "Lithuania", - "Acronym": "LTU" - }, - { - "FlagName": "LU", - "FullName": "Luxembourg", - "Acronym": "LUX" - }, - { - "FlagName": "LR", - "FullName": "Liberia", - "Acronym": "LBR" - }, - { - "FlagName": "LS", - "FullName": "Lesotho", - "Acronym": "LSO" - }, - { - "FlagName": "TH", - "FullName": "Thailand", - "Acronym": "THA" - }, - { - "FlagName": "TF", - "FullName": "French Southern Territories", - "Acronym": "ATF" - }, - { - "FlagName": "TG", - "FullName": "Togo", - "Acronym": "TGO" - }, - { - "FlagName": "TD", - "FullName": "Chad", - "Acronym": "TCD" - }, - { - "FlagName": "TC", - "FullName": "Turks and Caicos Islands", - "Acronym": "TCA" - }, - { - "FlagName": "LY", - "FullName": "Libya", - "Acronym": "LBY" - }, - { - "FlagName": "VA", - "FullName": "Vatican", - "Acronym": "VAT" - }, - { - "FlagName": "VC", - "FullName": "Saint Vincent and the Grenadines", - "Acronym": "VCT" - }, - { - "FlagName": "AE", - "FullName": "United Arab Emirates", - "Acronym": "ARE" - }, - { - "FlagName": "AD", - "FullName": "Andorra", - "Acronym": "AND" - }, - { - "FlagName": "AG", - "FullName": "Antigua and Barbuda", - "Acronym": "ATG" - }, - { - "FlagName": "AF", - "FullName": "Afghanistan", - "Acronym": "AFG" - }, - { - "FlagName": "AI", - "FullName": "Anguilla", - "Acronym": "AIA" - }, - { - "FlagName": "VI", - "FullName": "U.S. Virgin Islands", - "Acronym": "VIR" - }, - { - "FlagName": "IS", - "FullName": "Iceland", - "Acronym": "ISL" - }, - { - "FlagName": "IR", - "FullName": "Iran", - "Acronym": "IRN" - }, - { - "FlagName": "AM", - "FullName": "Armenia", - "Acronym": "ARM" - }, - { - "FlagName": "AL", - "FullName": "Albania", - "Acronym": "ALB" - }, - { - "FlagName": "AO", - "FullName": "Angola", - "Acronym": "AGO" - }, - { - "FlagName": "AQ", - "FullName": "Antarctica", - "Acronym": "ATA" - }, - { - "FlagName": "AS", - "FullName": "American Samoa", - "Acronym": "ASM" - }, - { - "FlagName": "AR", - "FullName": "Argentina", - "Acronym": "ARG" - }, - { - "FlagName": "AU", - "FullName": "Australia", - "Acronym": "AUS" - }, - { - "FlagName": "AT", - "FullName": "Austria", - "Acronym": "AUT" - }, - { - "FlagName": "AW", - "FullName": "Aruba", - "Acronym": "ABW" - }, - { - "FlagName": "IN", - "FullName": "India", - "Acronym": "IND" - }, - { - "FlagName": "AX", - "FullName": "Aland Islands", - "Acronym": "ALA" - }, - { - "FlagName": "AZ", - "FullName": "Azerbaijan", - "Acronym": "AZE" - }, - { - "FlagName": "IE", - "FullName": "Ireland", - "Acronym": "IRL" - }, - { - "FlagName": "ID", - "FullName": "Indonesia", - "Acronym": "IDN" - }, - { - "FlagName": "UA", - "FullName": "Ukraine", - "Acronym": "UKR" - }, - { - "FlagName": "QA", - "FullName": "Qatar", - "Acronym": "QAT" - }, - { - "FlagName": "MZ", - "FullName": "Mozambique", - "Acronym": "MOZ" - } -] \ No newline at end of file diff --git a/osu.Game.Tournament/SaveChangesOverlay.cs b/osu.Game.Tournament/SaveChangesOverlay.cs new file mode 100644 index 0000000000..b5e08fc005 --- /dev/null +++ b/osu.Game.Tournament/SaveChangesOverlay.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Tournament +{ + internal class SaveChangesOverlay : CompositeDrawable + { + [Resolved] + private TournamentGame tournamentGame { get; set; } = null!; + + private string? lastSerialisedLadder; + private readonly TourneyButton saveChangesButton; + + public SaveChangesOverlay() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new Container + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Position = new Vector2(5), + CornerRadius = 10, + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + RelativeSizeAxes = Axes.Both, + }, + saveChangesButton = new TourneyButton + { + Text = "Save Changes", + Width = 140, + Height = 50, + Padding = new MarginPadding + { + Top = 10, + Left = 10, + }, + Margin = new MarginPadding + { + Right = 10, + Bottom = 10, + }, + Action = saveChanges, + // Enabled = { Value = false }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + scheduleNextCheck(); + } + + private async Task checkForChanges() + { + string serialisedLadder = await Task.Run(() => tournamentGame.GetSerialisedLadder()); + + // If a save hasn't been triggered by the user yet, populate the initial value + lastSerialisedLadder ??= serialisedLadder; + + if (lastSerialisedLadder != serialisedLadder && !saveChangesButton.Enabled.Value) + { + saveChangesButton.Enabled.Value = true; + saveChangesButton.Background + .FadeColour(saveChangesButton.BackgroundColour.Lighten(0.5f), 500, Easing.In).Then() + .FadeColour(saveChangesButton.BackgroundColour, 500, Easing.Out) + .Loop(); + } + + scheduleNextCheck(); + } + + private void scheduleNextCheck() => Scheduler.AddDelayed(() => checkForChanges().FireAndForget(), 1000); + + private void saveChanges() + { + tournamentGame.SaveChanges(); + lastSerialisedLadder = tournamentGame.GetSerialisedLadder(); + + saveChangesButton.Enabled.Value = false; + saveChangesButton.Background.FadeColour(saveChangesButton.BackgroundColour, 500); + } + } +} diff --git a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs index f50abd6e58..0b1a5328ab 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs @@ -93,7 +93,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components { allTeams.RemoveAll(gt => gt.Team == team); - if (teams.RemoveAll(gt => gt.Team == team) > 0) + if (teams.RemoveAll(gt => gt.Team == team, true) > 0) { TeamsCount--; return true; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs index 52c611d323..8092c24ccb 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs @@ -170,7 +170,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components availableTeams.Add(team); - RemoveAll(c => c is ScrollingTeam); + RemoveAll(c => c is ScrollingTeam, true); setScrollState(ScrollState.Idle); } @@ -186,7 +186,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components public void ClearTeams() { availableTeams.Clear(); - RemoveAll(c => c is ScrollingTeam); + RemoveAll(c => c is ScrollingTeam, true); setScrollState(ScrollState.Idle); } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs index 15edbb76c1..663162d1ca 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs @@ -57,7 +57,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components if (allLines.Count == 0) return; - Remove(allLines.First()); + Remove(allLines.First(), true); allLines.Remove(allLines.First()); } diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index 32da4d1b36..5ac25f97b5 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -12,8 +12,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; @@ -45,7 +43,7 @@ namespace osu.Game.Tournament.Screens.Drawings public ITeamList TeamList; [BackgroundDependencyLoader] - private void load(TextureStore textures, Storage storage) + private void load(Storage storage) { RelativeSizeAxes = Axes.Both; @@ -91,11 +89,10 @@ namespace osu.Game.Tournament.Screens.Drawings RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new Sprite + new TourneyVideo("drawings") { + Loop = true, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, - Texture = textures.Get(@"Backgrounds/Drawings/background.png") }, // Visualiser new VisualiserContainer diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 11db37c8b7..da27c09e01 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -3,13 +3,13 @@ #nullable disable +using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; -using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -25,9 +25,6 @@ namespace osu.Game.Tournament.Screens.Editors { public class TeamEditorScreen : TournamentEditorScreen { - [Resolved] - private TournamentGameBase game { get; set; } - protected override BindableList Storage => LadderInfo.Teams; [BackgroundDependencyLoader] @@ -45,11 +42,17 @@ namespace osu.Game.Tournament.Screens.Editors private void addAllCountries() { - List countries; + var countries = new List(); - using (Stream stream = game.Resources.GetStream("Resources/countries.json")) - using (var sr = new StreamReader(stream)) - countries = JsonConvert.DeserializeObject>(sr.ReadToEnd()); + foreach (var country in Enum.GetValues(typeof(CountryCode)).Cast().Skip(1)) + { + countries.Add(new TournamentTeam + { + FlagName = { Value = country.ToString() }, + FullName = { Value = country.GetDescription() }, + Acronym = { Value = country.GetAcronym() }, + }); + } Debug.Assert(countries != null); @@ -298,10 +301,10 @@ namespace osu.Game.Tournament.Screens.Editors }, true); } - private void updatePanel() + private void updatePanel() => Scheduler.AddOnce(() => { drawableContainer.Child = new UserGridPanel(user.ToAPIUser()) { Width = 300 }; - } + }); } } } diff --git a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs index 8af5bbe513..8c55026c67 100644 --- a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs @@ -20,7 +20,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Editors { - public abstract class TournamentEditorScreen : TournamentScreen, IProvideVideo + public abstract class TournamentEditorScreen : TournamentScreen where TDrawable : Drawable, IModelBacked where TModel : class, new() { @@ -106,7 +106,7 @@ namespace osu.Game.Tournament.Screens.Editors break; case NotifyCollectionChangedAction.Remove: - args.OldItems.Cast().ForEach(i => flow.RemoveAll(d => d.Model == i)); + args.OldItems.Cast().ForEach(i => flow.RemoveAll(d => d.Model == i, true)); break; } }; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs index bb187c9e67..1eceddd871 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs @@ -16,6 +16,10 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components { private readonly TeamScore score; + private readonly TournamentSpriteTextWithBackground teamText; + + private readonly Bindable teamName = new Bindable("???"); + private bool showScore; public bool ShowScore @@ -93,7 +97,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components } } }, - new TournamentSpriteTextWithBackground(team?.FullName.Value ?? "???") + teamText = new TournamentSpriteTextWithBackground { Scale = new Vector2(0.5f), Origin = anchor, @@ -113,6 +117,11 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components updateDisplay(); FinishTransforms(true); + + if (Team != null) + teamName.BindTo(Team.FullName); + + teamName.BindValueChanged(name => teamText.Text.Text = name.NewValue, true); } private void updateDisplay() diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs index ed11f097ed..0fa5884603 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs @@ -25,6 +25,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components public bool ShowScore { + get => teamDisplay.ShowScore; set => teamDisplay.ShowScore = value; } @@ -42,6 +43,8 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components currentMatch.BindTo(ladder.CurrentMatch); currentMatch.BindValueChanged(matchChanged); + currentTeam.BindValueChanged(teamChanged); + updateMatch(); } @@ -67,7 +70,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components // team may change to same team, which means score is not in a good state. // thus we handle this manually. - teamChanged(currentTeam.Value); + currentTeam.TriggerChange(); } protected override bool OnMouseDown(MouseDownEvent e) @@ -88,12 +91,16 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components return base.OnMouseDown(e); } - private void teamChanged(TournamentTeam team) + private void teamChanged(ValueChangedEvent team) { + bool wasShowingScores = teamDisplay?.ShowScore ?? false; + InternalChildren = new Drawable[] { - teamDisplay = new TeamDisplay(team, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0), + teamDisplay = new TeamDisplay(team.NewValue, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0), }; + + teamDisplay.ShowScore = wasShowingScores; } } } diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index 86b2c2a4e9..8a23ee65da 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -21,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Gameplay { - public class GameplayScreen : BeatmapInfoScreen, IProvideVideo + public class GameplayScreen : BeatmapInfoScreen { private readonly BindableBool warmup = new BindableBool(); @@ -199,16 +199,19 @@ namespace osu.Game.Tournament.Screens.Gameplay case TourneyState.Idle: contract(); - const float delay_before_progression = 4000; - - // if we've returned to idle and the last screen was ranking - // we should automatically proceed after a short delay - if (lastState == TourneyState.Ranking && !warmup.Value) + if (LadderInfo.AutoProgressScreens.Value) { - if (CurrentMatch.Value?.Completed.Value == true) - scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(TeamWinScreen)); }, delay_before_progression); - else if (CurrentMatch.Value?.Completed.Value == false) - scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(MapPoolScreen)); }, delay_before_progression); + const float delay_before_progression = 4000; + + // if we've returned to idle and the last screen was ranking + // we should automatically proceed after a short delay + if (lastState == TourneyState.Ranking && !warmup.Value) + { + if (CurrentMatch.Value?.Completed.Value == true) + scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(TeamWinScreen)); }, delay_before_progression); + else if (CurrentMatch.Value?.Completed.Value == false) + scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(MapPoolScreen)); }, delay_before_progression); + } } break; diff --git a/osu.Game.Tournament/Screens/IProvideVideo.cs b/osu.Game.Tournament/Screens/IProvideVideo.cs deleted file mode 100644 index aa67a5211f..0000000000 --- a/osu.Game.Tournament/Screens/IProvideVideo.cs +++ /dev/null @@ -1,14 +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 - -namespace osu.Game.Tournament.Screens -{ - /// - /// Marker interface for a screen which provides its own local video background. - /// - public interface IProvideVideo - { - } -} diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index 5204edf3be..ed8b789387 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -280,7 +280,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components protected override bool OnClick(ClickEvent e) { - if (editorInfo == null || Match is ConditionalTournamentMatch) + if (editorInfo == null || Match is ConditionalTournamentMatch || e.Button != MouseButton.Left) return false; Selected = true; diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs index f0eda5672a..1fdf616e34 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs @@ -53,6 +53,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components editorInfo.Selected.ValueChanged += selection => { + // ensure any ongoing edits are committed out to the *current* selection before changing to a new one. + GetContainingInputManager().TriggerFocusContention(null); + roundDropdown.Current = selection.NewValue?.Round; losersCheckbox.Current = selection.NewValue?.Losers; dateTimeBox.Current = selection.NewValue?.Date; diff --git a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs index 23bfa84afc..7ad7e76a1f 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs @@ -19,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Ladder { - public class LadderScreen : TournamentScreen, IProvideVideo + public class LadderScreen : TournamentScreen { protected Container MatchesContainer; private Container paths; diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 5eb2142fae..decd723814 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -46,7 +46,10 @@ namespace osu.Game.Tournament.Screens.MapPool Loop = true, RelativeSizeAxes = Axes.Both, }, - new MatchHeader(), + new MatchHeader + { + ShowScores = true, + }, mapFlows = new FillFlowContainer> { Y = 160, @@ -197,10 +200,13 @@ namespace osu.Game.Tournament.Screens.MapPool setNextMode(); - if (pickType == ChoiceType.Pick && CurrentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) + if (LadderInfo.AutoProgressScreens.Value) { - scheduledChange?.Cancel(); - scheduledChange = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(GameplayScreen)); }, 10000); + if (pickType == ChoiceType.Pick && CurrentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) + { + scheduledChange?.Cancel(); + scheduledChange = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(GameplayScreen)); }, 10000); + } } } diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index 7a11e26794..0827cbae69 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -19,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Schedule { - public class ScheduleScreen : TournamentScreen // IProvidesVideo + public class ScheduleScreen : TournamentScreen { private readonly Bindable currentMatch = new Bindable(); private Container mainContainer; diff --git a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs index 42eff3565f..ff781dec80 100644 --- a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs @@ -9,6 +9,8 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Overlays; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Setup { - public class SetupScreen : TournamentScreen, IProvideVideo + public class SetupScreen : TournamentScreen { private FillFlowContainer fillFlow; @@ -48,13 +50,21 @@ namespace osu.Game.Tournament.Screens.Setup { windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); - InternalChild = fillFlow = new FillFlowContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Padding = new MarginPadding(10), - Spacing = new Vector2(10), + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.2f), + }, + fillFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(10), + Spacing = new Vector2(10), + } }; api.LocalUser.BindValueChanged(_ => Schedule(reload)); @@ -74,7 +84,8 @@ namespace osu.Game.Tournament.Screens.Setup Action = () => sceneManager?.SetScreen(new StablePathSelectScreen()), Value = fileBasedIpc?.IPCStorage?.GetFullPath(string.Empty) ?? "Not found", Failing = fileBasedIpc?.IPCStorage == null, - Description = "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation." + Description = + "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation." }, new ActionableInfo { @@ -120,6 +131,12 @@ namespace osu.Game.Tournament.Screens.Setup windowSize.Value = new Size((int)(height * aspect_ratio / TournamentSceneManager.STREAM_AREA_WIDTH * TournamentSceneManager.REQUIRED_WIDTH), height); } }, + new LabelledSwitchButton + { + Label = "Auto advance screens", + Description = "Screens will progress automatically from gameplay -> results -> map pool", + Current = LadderInfo.AutoProgressScreens, + }, }; } diff --git a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs index 082aa99b0e..a7a175ceba 100644 --- a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs +++ b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs @@ -14,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Showcase { - public class ShowcaseScreen : BeatmapInfoScreen // IProvideVideo + public class ShowcaseScreen : BeatmapInfoScreen { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 925c697346..9262cab098 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -19,7 +20,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class SeedingScreen : TournamentMatchScreen, IProvideVideo + public class SeedingScreen : TournamentMatchScreen { private Container mainContainer; @@ -69,7 +70,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro currentTeam.BindValueChanged(teamChanged, true); } - private void teamChanged(ValueChangedEvent team) + private void teamChanged(ValueChangedEvent team) => Scheduler.AddOnce(() => { if (team.NewValue == null) { @@ -78,7 +79,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro } showTeam(team.NewValue); - } + }); protected override void CurrentMatchChanged(ValueChangedEvent match) { @@ -120,8 +121,14 @@ namespace osu.Game.Tournament.Screens.TeamIntro foreach (var seeding in team.SeedingResults) { fill.Add(new ModRow(seeding.Mod.Value, seeding.Seed.Value)); + foreach (var beatmap in seeding.Beatmaps) + { + if (beatmap.Beatmap == null) + continue; + fill.Add(new BeatmapScoreRow(beatmap)); + } } } @@ -129,6 +136,8 @@ namespace osu.Game.Tournament.Screens.TeamIntro { public BeatmapScoreRow(SeedingBeatmap beatmap) { + Debug.Assert(beatmap.Beatmap != null); + RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -157,7 +166,8 @@ namespace osu.Game.Tournament.Screens.TeamIntro Children = new Drawable[] { new TournamentSpriteText { Text = beatmap.Score.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Width = 80 }, - new TournamentSpriteText { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + new TournamentSpriteText + { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, } }, }; diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index 98dfaa7487..08c9a7a897 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -13,7 +13,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class TeamIntroScreen : TournamentMatchScreen, IProvideVideo + public class TeamIntroScreen : TournamentMatchScreen { private Container mainContainer; diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index 50207547cd..ac54ff58f5 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamWin { - public class TeamWinScreen : TournamentMatchScreen, IProvideVideo + public class TeamWinScreen : TournamentMatchScreen { private Container mainContainer; @@ -66,7 +66,7 @@ namespace osu.Game.Tournament.Screens.TeamWin private bool firstDisplay = true; - private void update() => Schedule(() => + private void update() => Scheduler.AddOnce(() => { var match = CurrentMatch.Value; diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 537fbfc038..7d67bfa759 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -11,8 +11,6 @@ using osu.Framework.Configuration; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Logging; using osu.Framework.Platform; @@ -20,11 +18,11 @@ using osu.Game.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Models; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament { + [Cached] public class TournamentGame : TournamentGameBase { public static ColourInfo GetTeamColour(TeamColour teamColour) => teamColour == TeamColour.Red ? COLOUR_RED : COLOUR_BLUE; @@ -78,40 +76,9 @@ namespace osu.Game.Tournament LoadComponentsAsync(new[] { - new Container + new SaveChangesOverlay { - CornerRadius = 10, Depth = float.MinValue, - Position = new Vector2(5), - Masking = true, - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Children = new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.2f), - RelativeSizeAxes = Axes.Both, - }, - new TourneyButton - { - Text = "Save Changes", - Width = 140, - Height = 50, - Padding = new MarginPadding - { - Top = 10, - Left = 10, - }, - Margin = new MarginPadding - { - Right = 10, - Bottom = 10, - }, - Action = SaveChanges, - }, - } }, heightWarning = new WarningBox("Please make the window wider") { diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 75c9f17d4c..1861e39c60 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -21,6 +21,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Tournament.IO; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; +using osu.Game.Users; using osuTK.Input; namespace osu.Game.Tournament @@ -69,10 +70,10 @@ namespace osu.Game.Tournament protected override void LoadComplete() { - MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display + GlobalCursorDisplay.MenuCursor.AlwaysPresent = true; // required for tooltip display // we don't want to show the menu cursor as it would appear on stream output. - MenuCursorContainer.Cursor.Alpha = 0; + GlobalCursorDisplay.MenuCursor.Alpha = 0; base.LoadComplete(); @@ -186,7 +187,9 @@ namespace osu.Game.Tournament { var playersRequiringPopulation = ladder.Teams .SelectMany(t => t.Players) - .Where(p => string.IsNullOrEmpty(p.Username) || p.Rank == null).ToList(); + .Where(p => string.IsNullOrEmpty(p.Username) + || p.CountryCode == CountryCode.Unknown + || p.Rank == null).ToList(); if (playersRequiringPopulation.Count == 0) return false; @@ -288,14 +291,14 @@ namespace osu.Game.Tournament user.Username = res.Username; user.CoverUrl = res.CoverUrl; - user.Country = res.Country; + user.CountryCode = res.CountryCode; user.Rank = res.Statistics?.GlobalRank; success?.Invoke(); } } - protected virtual void SaveChanges() + public void SaveChanges() { if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully) { @@ -311,7 +314,16 @@ namespace osu.Game.Tournament .ToList(); // Serialise before opening stream for writing, so if there's a failure it will leave the file in the previous state. - string serialisedLadder = JsonConvert.SerializeObject(ladder, + string serialisedLadder = GetSerialisedLadder(); + + using (var stream = storage.CreateFileSafely(BRACKET_FILENAME)) + using (var sw = new StreamWriter(stream)) + sw.Write(serialisedLadder); + } + + public string GetSerialisedLadder() + { + return JsonConvert.SerializeObject(ladder, new JsonSerializerSettings { Formatting = Formatting.Indented, @@ -319,10 +331,6 @@ namespace osu.Game.Tournament DefaultValueHandling = DefaultValueHandling.Ignore, Converters = new JsonConverter[] { new JsonPointConverter() } }); - - using (var stream = storage.CreateFileSafely(BRACKET_FILENAME)) - using (var sw = new StreamWriter(stream)) - sw.Write(serialisedLadder); } protected override UserInputManager CreateUserInputManager() => new TournamentInputManager(); diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index 296b259d72..a12dbb4740 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -186,7 +187,7 @@ namespace osu.Game.Tournament var lastScreen = currentScreen; currentScreen = target; - if (currentScreen is IProvideVideo) + if (currentScreen.ChildrenOfType().FirstOrDefault()?.VideoAvailable == true) { video.FadeOut(200); diff --git a/osu.Game.Tournament/TourneyButton.cs b/osu.Game.Tournament/TourneyButton.cs index f5a82771f5..f1b14df783 100644 --- a/osu.Game.Tournament/TourneyButton.cs +++ b/osu.Game.Tournament/TourneyButton.cs @@ -3,12 +3,15 @@ #nullable disable +using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.UserInterface; namespace osu.Game.Tournament { public class TourneyButton : OsuButton { + public new Box Background => base.Background; + public TourneyButton() : base(null) { diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs index 5c318eb957..9446967173 100644 --- a/osu.Game/Audio/Effects/AudioFilter.cs +++ b/osu.Game/Audio/Effects/AudioFilter.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.Diagnostics; using ManagedBass.Fx; using osu.Framework.Audio.Mixing; diff --git a/osu.Game/Audio/Effects/ITransformableFilter.cs b/osu.Game/Audio/Effects/ITransformableFilter.cs index 02149b362c..fb6a924f68 100644 --- a/osu.Game/Audio/Effects/ITransformableFilter.cs +++ b/osu.Game/Audio/Effects/ITransformableFilter.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.Graphics; using osu.Framework.Graphics.Transforms; diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 6aaf3d5cc2..efa5562cb8 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -14,15 +14,15 @@ namespace osu.Game.Audio [Serializable] public class HitSampleInfo : ISampleInfo, IEquatable { + public const string HIT_NORMAL = @"hitnormal"; public const string HIT_WHISTLE = @"hitwhistle"; public const string HIT_FINISH = @"hitfinish"; - public const string HIT_NORMAL = @"hitnormal"; public const string HIT_CLAP = @"hitclap"; /// /// All valid sample addition constants. /// - public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_CLAP, HIT_FINISH }; + public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_FINISH, HIT_CLAP }; /// /// The name of the sample to load. diff --git a/osu.Game/Audio/IPreviewTrackOwner.cs b/osu.Game/Audio/IPreviewTrackOwner.cs index 6a3acc2059..8ab93257a5 100644 --- a/osu.Game/Audio/IPreviewTrackOwner.cs +++ b/osu.Game/Audio/IPreviewTrackOwner.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 - namespace osu.Game.Audio { /// diff --git a/osu.Game/Audio/ISampleInfo.cs b/osu.Game/Audio/ISampleInfo.cs index 8f58415587..4f81d37e78 100644 --- a/osu.Game/Audio/ISampleInfo.cs +++ b/osu.Game/Audio/ISampleInfo.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; namespace osu.Game.Audio diff --git a/osu.Game/Audio/ISamplePlaybackDisabler.cs b/osu.Game/Audio/ISamplePlaybackDisabler.cs index 250e004b05..4167316780 100644 --- a/osu.Game/Audio/ISamplePlaybackDisabler.cs +++ b/osu.Game/Audio/ISamplePlaybackDisabler.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.Allocation; using osu.Framework.Bindables; using osu.Game.Skinning; diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs index 8ff8cd5c54..2409ca6eb6 100644 --- a/osu.Game/Audio/PreviewTrack.cs +++ b/osu.Game/Audio/PreviewTrack.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; using osu.Framework.Allocation; using osu.Framework.Audio.Track; @@ -18,15 +16,15 @@ namespace osu.Game.Audio /// Invoked when this has stopped playing. /// Not invoked in a thread-safe context. /// - public event Action Stopped; + public event Action? Stopped; /// /// Invoked when this has started playing. /// Not invoked in a thread-safe context. /// - public event Action Started; + public event Action? Started; - protected Track Track { get; private set; } + protected Track? Track { get; private set; } private bool hasStarted; @@ -58,7 +56,7 @@ namespace osu.Game.Audio /// public bool IsRunning => Track?.IsRunning ?? false; - private ScheduledDelegate startDelegate; + private ScheduledDelegate? startDelegate; /// /// Starts playing this . @@ -106,6 +104,12 @@ namespace osu.Game.Audio /// /// Retrieves the audio track. /// - protected abstract Track GetTrack(); + protected abstract Track? GetTrack(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + Track?.Dispose(); + } } } diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index d19fdbd94c..b8662b6a4b 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.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.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; @@ -20,9 +18,9 @@ namespace osu.Game.Audio private readonly BindableDouble muteBindable = new BindableDouble(); - private ITrackStore trackStore; + private ITrackStore trackStore = null!; - protected TrackManagerPreviewTrack CurrentTrack; + protected TrackManagerPreviewTrack? CurrentTrack; public PreviewTrackManager(IAdjustableAudioComponent mainTrackAdjustments) { @@ -89,8 +87,8 @@ namespace osu.Game.Audio public class TrackManagerPreviewTrack : PreviewTrack { - [Resolved(canBeNull: true)] - public IPreviewTrackOwner Owner { get; private set; } + [Resolved] + public IPreviewTrackOwner? Owner { get; private set; } private readonly IBeatmapSetInfo beatmapSetInfo; private readonly ITrackStore trackManager; diff --git a/osu.Game/Audio/SampleInfo.cs b/osu.Game/Audio/SampleInfo.cs index 54b380f23a..19c78f34b2 100644 --- a/osu.Game/Audio/SampleInfo.cs +++ b/osu.Game/Audio/SampleInfo.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; using System.Collections; using System.Collections.Generic; @@ -34,7 +32,7 @@ namespace osu.Game.Audio Volume); } - public bool Equals(SampleInfo other) + public bool Equals(SampleInfo? other) => other != null && sampleNames.SequenceEqual(other.sampleNames); public override bool Equals(object obj) diff --git a/osu.Game/BackgroundBeatmapProcessor.cs b/osu.Game/BackgroundBeatmapProcessor.cs new file mode 100644 index 0000000000..ea5904a8d3 --- /dev/null +++ b/osu.Game/BackgroundBeatmapProcessor.cs @@ -0,0 +1,197 @@ +// 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.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game +{ + public class BackgroundBeatmapProcessor : Component + { + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private BeatmapUpdater beatmapUpdater { get; set; } = null!; + + [Resolved] + private IBindable gameBeatmap { get; set; } = null!; + + [Resolved] + private ILocalUserPlayInfo? localUserPlayInfo { get; set; } + + protected virtual int TimeToSleepDuringGameplay => 30000; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Task.Run(() => + { + Logger.Log("Beginning background beatmap processing.."); + checkForOutdatedStarRatings(); + processBeatmapSetsWithMissingMetrics(); + processScoresWithMissingStatistics(); + }).ContinueWith(t => + { + if (t.Exception?.InnerException is ObjectDisposedException) + { + Logger.Log("Finished background aborted during shutdown"); + return; + } + + Logger.Log("Finished background beatmap processing!"); + }); + } + + /// + /// Check whether the databased difficulty calculation version matches the latest ruleset provided version. + /// If it doesn't, clear out any existing difficulties so they can be incrementally recalculated. + /// + private void checkForOutdatedStarRatings() + { + foreach (var ruleset in rulesetStore.AvailableRulesets) + { + // beatmap being passed in is arbitrary here. just needs to be non-null. + int currentVersion = ruleset.CreateInstance().CreateDifficultyCalculator(gameBeatmap.Value).Version; + + if (ruleset.LastAppliedDifficultyVersion < currentVersion) + { + Logger.Log($"Resetting star ratings for {ruleset.Name} (difficulty calculation version updated from {ruleset.LastAppliedDifficultyVersion} to {currentVersion})"); + + int countReset = 0; + + realmAccess.Write(r => + { + foreach (var b in r.All()) + { + if (b.Ruleset.ShortName == ruleset.ShortName) + { + b.StarRating = -1; + countReset++; + } + } + + r.Find(ruleset.ShortName).LastAppliedDifficultyVersion = currentVersion; + }); + + Logger.Log($"Finished resetting {countReset} beatmap sets for {ruleset.Name}"); + } + } + } + + private void processBeatmapSetsWithMissingMetrics() + { + HashSet beatmapSetIds = new HashSet(); + + Logger.Log("Querying for beatmap sets to reprocess..."); + + realmAccess.Run(r => + { + foreach (var b in r.All().Where(b => b.StarRating < 0 || (b.OnlineID > 0 && b.LastOnlineUpdate == null))) + { + Debug.Assert(b.BeatmapSet != null); + beatmapSetIds.Add(b.BeatmapSet.ID); + } + }); + + Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require reprocessing."); + + int i = 0; + + foreach (var id in beatmapSetIds) + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + + realmAccess.Run(r => + { + var set = r.Find(id); + + if (set != null) + { + try + { + Logger.Log($"Background processing {set} ({++i} / {beatmapSetIds.Count})"); + beatmapUpdater.Process(set); + } + catch (Exception e) + { + Logger.Log($"Background processing failed on {set}: {e}"); + } + } + }); + } + } + + private void processScoresWithMissingStatistics() + { + HashSet scoreIds = new HashSet(); + + Logger.Log("Querying for scores to reprocess..."); + + realmAccess.Run(r => + { + foreach (var score in r.All()) + { + if (score.Statistics.Sum(kvp => kvp.Value) > 0 && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0) + scoreIds.Add(score.ID); + } + }); + + Logger.Log($"Found {scoreIds.Count} scores which require reprocessing."); + + foreach (var id in scoreIds) + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + + try + { + var score = scoreManager.Query(s => s.ID == id); + + scoreManager.PopulateMaximumStatistics(score); + + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + realmAccess.Write(r => + { + r.Find(id).MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); + }); + + Logger.Log($"Populated maximum statistics for score {id}"); + } + catch (Exception e) + { + Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}"); + } + } + } + } +} diff --git a/osu.Game/Beatmaps/BeatSyncProviderExtensions.cs b/osu.Game/Beatmaps/BeatSyncProviderExtensions.cs new file mode 100644 index 0000000000..767aa5df73 --- /dev/null +++ b/osu.Game/Beatmaps/BeatSyncProviderExtensions.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps +{ + public static class BeatSyncProviderExtensions + { + /// + /// Check whether beat sync is currently available. + /// + public static bool CheckBeatSyncAvailable(this IBeatSyncProvider provider) => provider.Clock != null; + + /// + /// Whether the beat sync provider is currently in a kiai section. Should make everything more epic. + /// + public static bool CheckIsKiaiTime(this IBeatSyncProvider provider) => provider.Clock != null && provider.ControlPoints?.EffectPointAt(provider.Clock.CurrentTime).KiaiMode == true; + } +} diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index c499bccb68..2d02fb6200 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -14,9 +14,6 @@ using osu.Game.IO.Serialization.Converters; namespace osu.Game.Beatmaps { - /// - /// A Beatmap containing converted HitObjects. - /// public class Beatmap : IBeatmap where T : HitObject { diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 92f1fc17d5..bcb1d7f961 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -3,19 +3,23 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using Realms; @@ -31,13 +35,118 @@ namespace osu.Game.Beatmaps protected override string[] HashableFileTypes => new[] { ".osu" }; - public Action? ProcessBeatmap { private get; set; } + public Action<(BeatmapSetInfo beatmapSet, bool isBatch)>? ProcessBeatmap { private get; set; } public BeatmapImporter(Storage storage, RealmAccess realm) : base(storage, realm) { } + public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) + { + var imported = await Import(notification, importTask); + + if (!imported.Any()) + return null; + + Debug.Assert(imported.Count() == 1); + + var first = imported.First(); + + // If there were no changes, ensure we don't accidentally nuke ourselves. + if (first.ID == original.ID) + { + first.PerformRead(s => + { + // Re-run processing even in this case. We might have outdated metadata. + ProcessBeatmap?.Invoke((s, false)); + }); + return first; + } + + first.PerformWrite(updated => + { + var realm = updated.Realm; + + Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); + + original = realm.Find(original.ID); + + // Generally the import process will do this for us if the OnlineIDs match, + // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). + original.DeletePending = true; + + // Transfer local values which should be persisted across a beatmap update. + updated.DateAdded = original.DateAdded; + + transferCollectionReferences(realm, original, updated); + + foreach (var beatmap in original.Beatmaps.ToArray()) + { + var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash); + + if (updatedBeatmap != null) + { + // If the updated beatmap matches an existing one, transfer any user data across.. + if (beatmap.Scores.Any()) + { + Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database); + + foreach (var score in beatmap.Scores) + score.BeatmapInfo = updatedBeatmap; + } + + // ..then nuke the old beatmap completely. + // this is done instead of a soft deletion to avoid a user potentially creating weird + // interactions, like restoring the outdated beatmap then updating a second time + // (causing user data to be wiped). + original.Beatmaps.Remove(beatmap); + + realm.Remove(beatmap.Metadata); + realm.Remove(beatmap); + } + else + { + // If the beatmap differs in the original, leave it in a soft-deleted state but reset online info. + // This caters to the case where a user has made modifications they potentially want to restore, + // but after restoring we want to ensure it can't be used to trigger an update of the beatmap. + beatmap.ResetOnlineInfo(); + } + } + + // If the original has no beatmaps left, delete the set as well. + if (!original.Beatmaps.Any()) + realm.Remove(original); + }); + + return first; + } + + private static void transferCollectionReferences(Realm realm, BeatmapSetInfo original, BeatmapSetInfo updated) + { + // First check if every beatmap in the original set is in any collections. + // In this case, we will assume they also want any newly added difficulties added to the collection. + foreach (var c in realm.All()) + { + if (original.Beatmaps.Select(b => b.MD5Hash).All(c.BeatmapMD5Hashes.Contains)) + { + foreach (var b in original.Beatmaps) + c.BeatmapMD5Hashes.Remove(b.MD5Hash); + + foreach (var b in updated.Beatmaps) + c.BeatmapMD5Hashes.Add(b.MD5Hash); + } + } + + // Handle collections using permissive difficulty name to track difficulties. + foreach (var originalBeatmap in original.Beatmaps) + { + updated.Beatmaps + .FirstOrDefault(b => b.DifficultyName == originalBeatmap.DifficultyName)? + .TransferCollectionReferences(realm, originalBeatmap.MD5Hash); + } + } + protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == ".osz"; protected override void Populate(BeatmapSetInfo beatmapSet, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) @@ -80,26 +189,24 @@ namespace osu.Game.Beatmaps if (beatmapSet.OnlineID > 0) { - var existingSetWithSameOnlineID = realm.All().SingleOrDefault(b => b.OnlineID == beatmapSet.OnlineID); - - if (existingSetWithSameOnlineID != null) + // OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure. + foreach (var existingSetWithSameOnlineID in realm.All().Where(b => b.OnlineID == beatmapSet.OnlineID)) { existingSetWithSameOnlineID.DeletePending = true; existingSetWithSameOnlineID.OnlineID = -1; foreach (var b in existingSetWithSameOnlineID.Beatmaps) - b.OnlineID = -1; + b.ResetOnlineInfo(); - LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be deleted."); + LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be disassociated and marked for deletion."); } } } - protected override void PostImport(BeatmapSetInfo model, Realm realm) + protected override void PostImport(BeatmapSetInfo model, Realm realm, bool batchImport) { - base.PostImport(model, realm); - - ProcessBeatmap?.Invoke(model); + base.PostImport(model, realm, batchImport); + ProcessBeatmap?.Invoke((model, batchImport)); } private void validateOnlineIds(BeatmapSetInfo beatmapSet, Realm realm) @@ -134,7 +241,7 @@ namespace osu.Game.Beatmaps } } - void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineID = -1); + void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.ResetOnlineInfo()); } protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import) diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 346bf86818..6f9df1ba7f 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Testing; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; @@ -19,8 +21,12 @@ using Realms; namespace osu.Game.Beatmaps { /// - /// A single beatmap difficulty. + /// A realm model containing metadata for a single beatmap difficulty. + /// This should generally include anything which is required to be filtered on at song select, or anything pertaining to storage of beatmaps in the client. /// + /// + /// There are some legacy fields in this model which are not persisted to realm. These are isolated in a code region within the class and should eventually be migrated to `Beatmap`. + /// [ExcludeFromDynamicCompile] [Serializable] [MapTo("Beatmap")] @@ -86,14 +92,48 @@ namespace osu.Game.Beatmaps public string Hash { get; set; } = string.Empty; - public double StarRating { get; set; } + /// + /// Defaults to -1 (meaning not-yet-calculated). + /// Will likely be superseded with a better storage considering ruleset/mods. + /// + public double StarRating { get; set; } = -1; [Indexed] public string MD5Hash { get; set; } = string.Empty; + public string OnlineMD5Hash { get; set; } = string.Empty; + + /// + /// The last time of a local modification (via the editor). + /// + public DateTimeOffset? LastLocalUpdate { get; set; } + + /// + /// The last time online metadata was applied to this beatmap. + /// + public DateTimeOffset? LastOnlineUpdate { get; set; } + + /// + /// Whether this beatmap matches the online version, based on fetched online metadata. + /// Will return true if no online metadata is available. + /// + public bool MatchesOnlineVersion => LastOnlineUpdate == null || MD5Hash == OnlineMD5Hash; + [JsonIgnore] public bool Hidden { get; set; } + /// + /// Reset any fetched online linking information (and history). + /// + public void ResetOnlineInfo() + { + OnlineID = -1; + LastOnlineUpdate = null; + OnlineMD5Hash = string.Empty; + if (Status != BeatmapOnlineStatus.LocallyModified) + Status = BeatmapOnlineStatus.None; + } + #region Properties we may not want persisted (but also maybe no harm?) public double AudioLeadIn { get; set; } @@ -110,6 +150,11 @@ namespace osu.Game.Beatmaps public bool SamplesMatchPlaybackRate { get; set; } = true; + /// + /// The time at which this beatmap was last played by the local user. + /// + public DateTimeOffset? LastPlayed { get; set; } + /// /// The ratio of distance travelled per time unit. /// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ). @@ -151,14 +196,40 @@ namespace osu.Game.Beatmaps public bool AudioEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null - && BeatmapSet.Hash == other.BeatmapSet.Hash - && Metadata.AudioFile == other.Metadata.AudioFile; + && compareFiles(this, other, m => m.AudioFile); public bool BackgroundEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null - && BeatmapSet.Hash == other.BeatmapSet.Hash - && Metadata.BackgroundFile == other.Metadata.BackgroundFile; + && compareFiles(this, other, m => m.BackgroundFile); + + private static bool compareFiles(BeatmapInfo x, BeatmapInfo y, Func getFilename) + { + Debug.Assert(x.BeatmapSet != null); + Debug.Assert(y.BeatmapSet != null); + + string? fileHashX = x.BeatmapSet.GetFile(getFilename(x.Metadata))?.File.Hash; + string? fileHashY = y.BeatmapSet.GetFile(getFilename(y.Metadata))?.File.Hash; + + return fileHashX == fileHashY; + } + + /// + /// When updating a beatmap, its hashes will change. Collections currently track beatmaps by hash, so they need to be updated. + /// This method will handle updating + /// + /// A realm instance in an active write transaction. + /// The previous MD5 hash of the beatmap before update. + public void TransferCollectionReferences(Realm realm, string previousMD5Hash) + { + var collections = realm.All().AsEnumerable().Where(c => c.BeatmapMD5Hashes.Contains(previousMD5Hash)); + + foreach (var c in collections) + { + c.BeatmapMD5Hashes.Remove(previousMD5Hash); + c.BeatmapMD5Hashes.Add(MD5Hash); + } + } IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index e7ff07fa5d..eab66b9857 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.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 osu.Framework.Localisation; diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 30456afd2f..befc56d244 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -42,9 +42,9 @@ namespace osu.Game.Beatmaps private readonly WorkingBeatmapCache workingBeatmapCache; - public Action? ProcessBeatmap { private get; set; } + public Action<(BeatmapSetInfo beatmapSet, bool isBatch)>? ProcessBeatmap { private get; set; } - public BeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider? api, AudioManager audioManager, IResourceStore gameResources, GameHost? host = null, + public BeatmapManager(Storage storage, RealmAccess realm, IAPIProvider? api, AudioManager audioManager, IResourceStore gameResources, GameHost? host = null, WorkingBeatmap? defaultBeatmap = null, BeatmapDifficultyCache? difficultyCache = null, bool performOnlineLookups = false) : base(storage, realm) { @@ -62,7 +62,7 @@ namespace osu.Game.Beatmaps BeatmapTrackStore = audioManager.GetTrackStore(userResources); beatmapImporter = CreateBeatmapImporter(storage, realm); - beatmapImporter.ProcessBeatmap = obj => ProcessBeatmap?.Invoke(obj); + beatmapImporter.ProcessBeatmap = args => ProcessBeatmap?.Invoke(args); beatmapImporter.PostNotification = obj => PostNotification?.Invoke(obj); workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host); @@ -94,6 +94,7 @@ namespace osu.Game.Beatmaps var beatmapSet = new BeatmapSetInfo { + DateAdded = DateTimeOffset.UtcNow, Beatmaps = { new BeatmapInfo(ruleset, new BeatmapDifficulty(), metadata) @@ -164,8 +165,7 @@ namespace osu.Game.Beatmaps // clear the hash, as that's what is used to match .osu files with their corresponding realm beatmaps. newBeatmapInfo.Hash = string.Empty; // clear online properties. - newBeatmapInfo.OnlineID = -1; - newBeatmapInfo.Status = BeatmapOnlineStatus.None; + newBeatmapInfo.ResetOnlineInfo(); return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); } @@ -301,7 +301,7 @@ namespace osu.Game.Beatmaps stream.Seek(0, SeekOrigin.Begin); // AddFile generally handles updating/replacing files, but this is a case where the filename may have also changed so let's delete for simplicity. - var existingFileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)); + var existingFileInfo = beatmapInfo.Path != null ? setInfo.GetFile(beatmapInfo.Path) : null; string targetFilename = createBeatmapFilenameFromMetadata(beatmapInfo); // ensure that two difficulties from the set don't point at the same beatmap file. @@ -311,12 +311,17 @@ namespace osu.Game.Beatmaps if (existingFileInfo != null) DeleteFile(setInfo, existingFileInfo); + string oldMd5Hash = beatmapInfo.MD5Hash; + beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); beatmapInfo.Hash = stream.ComputeSHA2Hash(); + beatmapInfo.LastLocalUpdate = DateTimeOffset.Now; + beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified; + AddFile(setInfo, stream, createBeatmapFilenameFromMetadata(beatmapInfo)); - setInfo.Hash = beatmapImporter.ComputeHash(setInfo); + updateHashAndMarkDirty(setInfo); Realm.Write(r => { @@ -324,7 +329,9 @@ namespace osu.Game.Beatmaps setInfo.CopyChangesToRealm(liveBeatmapSet); - ProcessBeatmap?.Invoke(liveBeatmapSet); + beatmapInfo.TransferCollectionReferences(r, oldMd5Hash); + + ProcessBeatmap?.Invoke((liveBeatmapSet, false)); }); } @@ -359,6 +366,33 @@ namespace osu.Game.Beatmaps }); } + /// + /// Delete a beatmap difficulty immediately. + /// + /// + /// There's no undoing this operation, as we don't have a soft-deletion flag on . + /// This may be a future consideration if there's a user requirement for undeleting support. + /// + public void DeleteDifficultyImmediately(BeatmapInfo beatmapInfo) + { + Realm.Write(r => + { + if (!beatmapInfo.IsManaged) + beatmapInfo = r.Find(beatmapInfo.ID); + + Debug.Assert(beatmapInfo.BeatmapSet != null); + Debug.Assert(beatmapInfo.File != null); + + var setInfo = beatmapInfo.BeatmapSet; + + DeleteFile(setInfo, beatmapInfo.File); + setInfo.Beatmaps.Remove(beatmapInfo); + + updateHashAndMarkDirty(setInfo); + workingBeatmapCache.Invalidate(setInfo); + }); + } + /// /// Delete videos from a list of beatmaps. /// This will post notifications tracking progress. @@ -409,6 +443,15 @@ namespace osu.Game.Beatmaps Realm.Run(r => Undelete(r.All().Where(s => s.DeletePending).ToList())); } + public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) => + beatmapImporter.ImportAsUpdate(notification, importTask, original); + + private void updateHashAndMarkDirty(BeatmapSetInfo setInfo) + { + setInfo.Hash = beatmapImporter.ComputeHash(setInfo); + setInfo.Status = BeatmapOnlineStatus.LocallyModified; + } + #region Implementation of ICanAcceptFiles public Task Import(params string[] paths) => beatmapImporter.Import(paths); @@ -439,12 +482,15 @@ namespace osu.Game.Beatmaps { if (beatmapInfo != null) { - // Detached sets don't come with files. - // If we seem to be missing files, now is a good time to re-fetch. - if (refetch || beatmapInfo.IsManaged || beatmapInfo.BeatmapSet?.Files.Count == 0) - { + if (refetch) workingBeatmapCache.Invalidate(beatmapInfo); + // Detached beatmapsets don't come with files as an optimisation (see `RealmObjectExtensions.beatmap_set_mapper`). + // If we seem to be missing files, now is a good time to re-fetch. + bool missingFiles = beatmapInfo.BeatmapSet?.Files.Count == 0; + + if (refetch || beatmapInfo.IsManaged || missingFiles) + { Guid id = beatmapInfo.ID; beatmapInfo = Realm.Run(r => r.Find(id)?.Detach()) ?? beatmapInfo; } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index feb9d34f44..f645d914b1 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -12,6 +12,17 @@ using Realms; namespace osu.Game.Beatmaps { + /// + /// A realm model containing metadata for a beatmap. + /// + /// + /// This is currently stored against each beatmap difficulty, even when it is duplicated. + /// It is also provided via for convenience and historical purposes. + /// A future effort could see this converted to an or potentially de-duped + /// and shared across multiple difficulties in the same set, if required. + /// + /// Note that difficulty name is not stored in this metadata but in . + /// [ExcludeFromDynamicCompile] [Serializable] [MapTo("BeatmapMetadata")] diff --git a/osu.Game/Beatmaps/BeatmapModelDownloader.cs b/osu.Game/Beatmaps/BeatmapModelDownloader.cs index 74d583fe7e..4295def5c3 100644 --- a/osu.Game/Beatmaps/BeatmapModelDownloader.cs +++ b/osu.Game/Beatmaps/BeatmapModelDownloader.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.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -14,7 +12,7 @@ namespace osu.Game.Beatmaps protected override ArchiveDownloadRequest CreateDownloadRequest(IBeatmapSetInfo set, bool minimiseDownloadSize) => new DownloadBeatmapSetRequest(set, minimiseDownloadSize); - public override ArchiveDownloadRequest GetExistingDownload(IBeatmapSetInfo model) + public override ArchiveDownloadRequest? GetExistingDownload(IBeatmapSetInfo model) => CurrentDownloads.Find(r => r.Model.OnlineID == model.OnlineID); public BeatmapModelDownloader(IModelImporter beatmapImporter, IAPIProvider api) diff --git a/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs new file mode 100644 index 0000000000..5d0765641b --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Database; +using osu.Game.Online.Metadata; + +namespace osu.Game.Beatmaps +{ + /// + /// Ingests any changes that happen externally to the client, reprocessing as required. + /// + public class BeatmapOnlineChangeIngest : Component + { + private readonly BeatmapUpdater beatmapUpdater; + private readonly RealmAccess realm; + private readonly MetadataClient metadataClient; + + public BeatmapOnlineChangeIngest(BeatmapUpdater beatmapUpdater, RealmAccess realm, MetadataClient metadataClient) + { + this.beatmapUpdater = beatmapUpdater; + this.realm = realm; + this.metadataClient = metadataClient; + + metadataClient.ChangedBeatmapSetsArrived += changesDetected; + } + + private void changesDetected(int[] beatmapSetIds) + { + // May want to batch incoming updates further if the background realm operations ever becomes a concern. + realm.Run(r => + { + foreach (int id in beatmapSetIds) + { + var matchingSet = r.All().FirstOrDefault(s => s.OnlineID == id); + + if (matchingSet != null) + beatmapUpdater.Queue(matchingSet.ToLive(realm), true); + } + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + metadataClient.ChangedBeatmapSetsArrived -= changesDetected; + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs index ced85e0908..cdd99d4432 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs @@ -3,13 +3,23 @@ #nullable disable +using System.ComponentModel; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps { public enum BeatmapOnlineStatus { + /// + /// This is a special status given when local changes are made via the editor. + /// Once in this state, online status changes should be ignored unless the beatmap is reverted or submitted. + /// + [Description("Local")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))] + LocallyModified = -4, + None = -3, [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusGraveyard))] diff --git a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs deleted file mode 100644 index 051366fb99..0000000000 --- a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs +++ /dev/null @@ -1,31 +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.ComponentModel.DataAnnotations; -using osu.Game.Database; -using osu.Game.IO; - -namespace osu.Game.Beatmaps -{ - public class BeatmapSetFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int BeatmapSetInfoID { get; set; } - - public EFBeatmapSetInfo BeatmapSetInfo { get; set; } - - public int FileInfoID { get; set; } - - public FileInfo FileInfo { get; set; } - - [Required] - public string Filename { get; set; } - - IFileInfo INamedFileUsage.File => FileInfo; - } -} diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 96d95b1a12..b90dfdba05 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -14,6 +14,9 @@ using Realms; namespace osu.Game.Beatmaps { + /// + /// A realm model containing metadata for a beatmap set (containing multiple s). + /// [ExcludeFromDynamicCompile] [MapTo("BeatmapSet")] public class BeatmapSetInfo : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IBeatmapSetInfo @@ -26,6 +29,16 @@ namespace osu.Game.Beatmaps public DateTimeOffset DateAdded { get; set; } + /// + /// The date this beatmap set was first submitted. + /// + public DateTimeOffset? DateSubmitted { get; set; } + + /// + /// The date this beatmap set was ranked. + /// + public DateTimeOffset? DateRanked { get; set; } + [JsonIgnore] public IBeatmapMetadataInfo Metadata => Beatmaps.FirstOrDefault()?.Metadata ?? new BeatmapMetadata(); @@ -71,13 +84,6 @@ namespace osu.Game.Beatmaps { } - /// - /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. - /// The path returned is relative to the user file storage. - /// - /// The name of the file to get the storage path of. - public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); - public bool Equals(BeatmapSetInfo? other) { if (ReferenceEquals(this, other)) return true; @@ -93,5 +99,7 @@ namespace osu.Game.Beatmaps IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; IEnumerable IHasNamedFiles.Files => Files; + + public bool AllBeatmapsUpToDate => Beatmaps.All(b => b.MatchesOnlineVersion); } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs new file mode 100644 index 0000000000..965544da40 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Models; + +namespace osu.Game.Beatmaps +{ + public static class BeatmapSetInfoExtensions + { + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// The lookup is case insensitive. + /// + /// The model to operate on. + /// The name of the file to get the storage path of. + public static string? GetPathForFile(this IHasRealmFiles model, string filename) => model.GetFile(filename)?.File.GetStoragePath(); + + /// + /// Returns the file usage for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// The lookup is case insensitive. + /// + /// The model to operate on. + /// The name of the file to get the storage path of. + public static RealmNamedFileUsage? GetFile(this IHasRealmFiles model, string filename) => + model.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs b/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs index b76496b145..aad31befa8 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineCovers.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 Newtonsoft.Json; namespace osu.Game.Beatmaps diff --git a/osu.Game/Beatmaps/BeatmapStatisticIcon.cs b/osu.Game/Beatmaps/BeatmapStatisticIcon.cs index 58d13a3172..8002910b52 100644 --- a/osu.Game/Beatmaps/BeatmapStatisticIcon.cs +++ b/osu.Game/Beatmaps/BeatmapStatisticIcon.cs @@ -3,10 +3,10 @@ #nullable disable -using Humanizer; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Game.Extensions; namespace osu.Game.Beatmaps { @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps [BackgroundDependencyLoader] private void load(TextureStore textures) { - Texture = textures.Get($"Icons/BeatmapDetails/{iconType.ToString().Kebaberize()}"); + Texture = textures.Get($"Icons/BeatmapDetails/{iconType.ToString().ToKebabCase()}"); } } diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 20fa0bc7c6..d7b1fac7b3 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -6,7 +6,9 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Rulesets.Objects; @@ -19,44 +21,45 @@ namespace osu.Game.Beatmaps public class BeatmapUpdater : IDisposable { private readonly IWorkingBeatmapCache workingBeatmapCache; - private readonly BeatmapOnlineLookupQueue onlineLookupQueue; + private readonly BeatmapDifficultyCache difficultyCache; + private readonly BeatmapUpdaterMetadataLookup metadataLookup; + + private const int update_queue_request_concurrency = 4; + + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapUpdaterMetadataLookup)); + public BeatmapUpdater(IWorkingBeatmapCache workingBeatmapCache, BeatmapDifficultyCache difficultyCache, IAPIProvider api, Storage storage) { this.workingBeatmapCache = workingBeatmapCache; this.difficultyCache = difficultyCache; - onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage); + metadataLookup = new BeatmapUpdaterMetadataLookup(api, storage); } /// /// Queue a beatmap for background processing. /// - public void Queue(int beatmapSetId) + /// The managed beatmap set to update. A transaction will be opened to apply changes. + /// Whether metadata from an online source should be preferred. If true, the local cache will be skipped to ensure the freshest data state possible. + public void Queue(Live beatmapSet, bool preferOnlineFetch = false) { - // TODO: implement - } - - /// - /// Queue a beatmap for background processing. - /// - public void Queue(Live beatmap) - { - // For now, just fire off a task. - // TODO: Add actual queueing probably. - Task.Factory.StartNew(() => beatmap.PerformRead(Process)); + Logger.Log($"Queueing change for local beatmap {beatmapSet}"); + Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, preferOnlineFetch)), default, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } /// /// Run all processing on a beatmap immediately. /// - public void Process(BeatmapSetInfo beatmapSet) => beatmapSet.Realm.Write(r => + /// The managed beatmap set to update. A transaction will be opened to apply changes. + /// Whether metadata from an online source should be preferred. If true, the local cache will be skipped to ensure the freshest data state possible. + public void Process(BeatmapSetInfo beatmapSet, bool preferOnlineFetch = false) => beatmapSet.Realm.Write(r => { // Before we use below, we want to invalidate. workingBeatmapCache.Invalidate(beatmapSet); - onlineLookupQueue.Update(beatmapSet); + metadataLookup.Update(beatmapSet, preferOnlineFetch); foreach (var beatmap in beatmapSet.Beatmaps) { @@ -96,8 +99,11 @@ namespace osu.Game.Beatmaps public void Dispose() { - if (onlineLookupQueue.IsNotNull()) - onlineLookupQueue.Dispose(); + if (metadataLookup.IsNotNull()) + metadataLookup.Dispose(); + + if (updateScheduler.IsNotNull()) + updateScheduler.Dispose(); } #endregion diff --git a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs similarity index 59% rename from osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs rename to osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs index a2eb76cafa..2fd1d06b7b 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs @@ -7,7 +7,6 @@ using System; using System.Diagnostics; using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using osu.Framework.Development; @@ -15,12 +14,12 @@ using osu.Framework.IO.Network; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; -using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using SharpCompress.Compressors; using SharpCompress.Compressors.BZip2; +using SQLitePCL; namespace osu.Game.Beatmaps { @@ -32,21 +31,28 @@ namespace osu.Game.Beatmaps /// This will always be checked before doing a second online query to get required metadata. /// [ExcludeFromDynamicCompile] - public class BeatmapOnlineLookupQueue : IDisposable + public class BeatmapUpdaterMetadataLookup : IDisposable { private readonly IAPIProvider api; private readonly Storage storage; - private const int update_queue_request_concurrency = 4; - - private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue)); - private FileWebRequest cacheDownloadRequest; private const string cache_database_name = "online.db"; - public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage) + public BeatmapUpdaterMetadataLookup(IAPIProvider api, Storage storage) { + try + { + // required to initialise native SQLite libraries on some platforms. + Batteries_V2.Init(); + raw.sqlite3_config(2 /*SQLITE_CONFIG_MULTITHREAD*/); + } + catch + { + // may fail if platform not supported. + } + this.api = api; this.storage = storage; @@ -55,27 +61,30 @@ namespace osu.Game.Beatmaps prepareLocalCache(); } - public void Update(BeatmapSetInfo beatmapSet) + /// + /// Queue an update for a beatmap set. + /// + /// + /// This may happen during initial import, or at a later stage in response to a user action or server event. + /// + /// The beatmap set to update. Updates will be applied directly (so a transaction should be started if this instance is managed). + /// Whether metadata from an online source should be preferred. If true, the local cache will be skipped to ensure the freshest data state possible. + public void Update(BeatmapSetInfo beatmapSet, bool preferOnlineFetch) { foreach (var b in beatmapSet.Beatmaps) - lookup(beatmapSet, b); + lookup(beatmapSet, b, preferOnlineFetch); } - public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) + private void lookup(BeatmapSetInfo set, BeatmapInfo beatmapInfo, bool preferOnlineFetch) { - return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); - } + bool apiAvailable = api?.State.Value == APIState.Online; - // todo: expose this when we need to do individual difficulty lookups. - protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmapInfo, CancellationToken cancellationToken) - => Task.Factory.StartNew(() => lookup(beatmapSet, beatmapInfo), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + bool useLocalCache = !apiAvailable || !preferOnlineFetch; - private void lookup(BeatmapSetInfo set, BeatmapInfo beatmapInfo) - { - if (checkLocalCache(set, beatmapInfo)) + if (useLocalCache && checkLocalCache(set, beatmapInfo)) return; - if (api?.State.Value != APIState.Online) + if (!apiAvailable) return; var req = new GetBeatmapRequest(beatmapInfo); @@ -88,7 +97,7 @@ namespace osu.Game.Beatmaps if (req.CompletionState == APIRequestCompletionState.Failed) { logForModel(set, $"Online retrieval failed for {beatmapInfo}"); - beatmapInfo.OnlineID = -1; + beatmapInfo.ResetOnlineInfo(); return; } @@ -96,15 +105,26 @@ namespace osu.Game.Beatmaps if (res != null) { - beatmapInfo.Status = res.Status; + beatmapInfo.OnlineID = res.OnlineID; + beatmapInfo.OnlineMD5Hash = res.MD5Hash; + beatmapInfo.LastOnlineUpdate = res.LastUpdated; Debug.Assert(beatmapInfo.BeatmapSet != null); - - beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapOnlineStatus.None; beatmapInfo.BeatmapSet.OnlineID = res.OnlineBeatmapSetID; - beatmapInfo.OnlineID = res.OnlineID; - beatmapInfo.Metadata.Author.OnlineID = res.AuthorID; + // Some metadata should only be applied if there's no local changes. + if (shouldSaveOnlineMetadata(beatmapInfo)) + { + beatmapInfo.Status = res.Status; + beatmapInfo.Metadata.Author.OnlineID = res.AuthorID; + } + + if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata)) + { + beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapOnlineStatus.None; + beatmapInfo.BeatmapSet.DateRanked = res.BeatmapSet?.Ranked; + beatmapInfo.BeatmapSet.DateSubmitted = res.BeatmapSet?.Submitted; + } logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineID}."); } @@ -112,7 +132,7 @@ namespace osu.Game.Beatmaps catch (Exception e) { logForModel(set, $"Online retrieval failed for {beatmapInfo} ({e.Message})"); - beatmapInfo.OnlineID = -1; + beatmapInfo.ResetOnlineInfo(); } } @@ -128,7 +148,7 @@ namespace osu.Game.Beatmaps File.Delete(compressedCacheFilePath); File.Delete(cacheFilePath); - Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database); + Logger.Log($"{nameof(BeatmapUpdaterMetadataLookup)}'s online cache download failed: {ex}", LoggingTarget.Database); }; cacheDownloadRequest.Finished += () => @@ -145,7 +165,7 @@ namespace osu.Game.Beatmaps } catch (Exception ex) { - Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database); + Logger.Log($"{nameof(BeatmapUpdaterMetadataLookup)}'s online cache extraction failed: {ex}", LoggingTarget.Database); File.Delete(cacheFilePath); } finally @@ -184,13 +204,14 @@ namespace osu.Game.Beatmaps try { - using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage))) + using (var db = new SqliteConnection(string.Concat("Data Source=", storage.GetFullPath($@"{"online.db"}", true)))) { db.Open(); using (var cmd = db.CreateCommand()) { - cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; + cmd.CommandText = + "SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmapInfo.MD5Hash)); cmd.Parameters.Add(new SqliteParameter("@OnlineID", beatmapInfo.OnlineID)); @@ -202,15 +223,25 @@ namespace osu.Game.Beatmaps { var status = (BeatmapOnlineStatus)reader.GetByte(2); - beatmapInfo.Status = status; + // Some metadata should only be applied if there's no local changes. + if (shouldSaveOnlineMetadata(beatmapInfo)) + { + beatmapInfo.Status = status; + beatmapInfo.Metadata.Author.OnlineID = reader.GetInt32(3); + } + + // TODO: DateSubmitted and DateRanked are not provided by local cache. + beatmapInfo.OnlineID = reader.GetInt32(1); + beatmapInfo.OnlineMD5Hash = reader.GetString(4); + beatmapInfo.LastOnlineUpdate = reader.GetDateTimeOffset(5); Debug.Assert(beatmapInfo.BeatmapSet != null); - - beatmapInfo.BeatmapSet.Status = status; beatmapInfo.BeatmapSet.OnlineID = reader.GetInt32(0); - beatmapInfo.OnlineID = reader.GetInt32(1); - beatmapInfo.Metadata.Author.OnlineID = reader.GetInt32(3); + if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata)) + { + beatmapInfo.BeatmapSet.Status = status; + } logForModel(set, $"Cached local retrieval for {beatmapInfo}."); return true; @@ -228,12 +259,17 @@ namespace osu.Game.Beatmaps } private void logForModel(BeatmapSetInfo set, string message) => - RealmArchiveModelImporter.LogForModel(set, $"[{nameof(BeatmapOnlineLookupQueue)}] {message}"); + RealmArchiveModelImporter.LogForModel(set, $"[{nameof(BeatmapUpdaterMetadataLookup)}] {message}"); + + /// + /// Check whether the provided beatmap is in a state where online "ranked" status metadata should be saved against it. + /// Handles the case where a user may have locally modified a beatmap in the editor and expects the local status to stick. + /// + private static bool shouldSaveOnlineMetadata(BeatmapInfo beatmapInfo) => beatmapInfo.MatchesOnlineVersion || beatmapInfo.Status != BeatmapOnlineStatus.LocallyModified; public void Dispose() { cacheDownloadRequest?.Dispose(); - updateScheduler?.Dispose(); } } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index 56a432aec4..0a09e6e7e6 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -9,11 +9,8 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable, IDeepCloneable, IEquatable + public abstract class ControlPoint : IComparable, IDeepCloneable, IEquatable, IControlPoint { - /// - /// The time at which the control point takes effect. - /// [JsonIgnore] public double Time { get; set; } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 4be6b5eede..422e306450 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -196,8 +196,8 @@ namespace osu.Game.Beatmaps.ControlPoints /// The time to find the control point at. /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. - protected T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) - where T : ControlPoint + public static T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) + where T : class, IControlPoint { return BinarySearch(list, time) ?? fallback; } @@ -207,9 +207,9 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The list to search. /// The time to find the control point at. - /// The active control point at . - protected virtual T BinarySearch(IReadOnlyList list, double time) - where T : ControlPoint + /// The active control point at . Will return null if there are no control points, or if the time is before the first control point. + public static T BinarySearch(IReadOnlyList list, double time) + where T : class, IControlPoint { if (list == null) throw new ArgumentNullException(nameof(list)); diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index c199d1da59..e6f1609d7f 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -24,7 +24,6 @@ namespace osu.Game.Beatmaps.ControlPoints public readonly BindableDouble SliderVelocityBindable = new BindableDouble(1) { Precision = 0.01, - Default = 1, MinValue = 0.1, MaxValue = 10 }; diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index ead07b4eaa..7c4313a015 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -28,7 +28,6 @@ namespace osu.Game.Beatmaps.ControlPoints public readonly BindableDouble ScrollSpeedBindable = new BindableDouble(1) { Precision = 0.01, - Default = 1, MinValue = 0.01, MaxValue = 10 }; diff --git a/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs new file mode 100644 index 0000000000..091e99e029 --- /dev/null +++ b/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps.ControlPoints +{ + public interface IControlPoint + { + /// + /// The time at which the control point takes effect. + /// + double Time { get; } + } +} diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 78dec67937..c454439c5c 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -45,7 +45,6 @@ namespace osu.Game.Beatmaps.ControlPoints { MinValue = 0, MaxValue = 100, - Default = 100 }; /// diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 23d4d10fd8..61cc060594 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -49,7 +49,6 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly BindableDouble BeatLengthBindable = new BindableDouble(DEFAULT_BEAT_LENGTH) { - Default = DEFAULT_BEAT_LENGTH, MinValue = 6, MaxValue = 60000 }; diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 88d4991c5d..23d90ab76e 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -6,15 +6,18 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class BeatmapSetOnlineStatusPill : CircularContainer + public class BeatmapSetOnlineStatusPill : CircularContainer, IHasTooltip { private BeatmapOnlineStatus status; @@ -96,5 +99,19 @@ namespace osu.Game.Beatmaps.Drawables background.Colour = OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeaFoamLighter; } + + public LocalisableString TooltipText + { + get + { + switch (Status) + { + case BeatmapOnlineStatus.LocallyModified: + return SongSelectStrings.LocallyModifiedTooltip; + } + + return string.Empty; + } + } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index b9e0a4e6cb..70312a1535 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -14,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Beatmaps.Drawables.Cards { - public abstract class BeatmapCard : OsuClickableContainer + public abstract class BeatmapCard : OsuClickableContainer, IEquatable { public const float TRANSITION_DURATION = 400; public const float CORNER_RADIUS = 10; @@ -96,5 +96,16 @@ namespace osu.Game.Beatmaps.Drawables.Cards throw new ArgumentOutOfRangeException(nameof(size), size, @"Unsupported card size"); } } + + public bool Equals(BeatmapCard? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return BeatmapSet.Equals(other.BeatmapSet); + } + + public override bool Equals(object obj) => obj is BeatmapCard other && Equals(other); + public override int GetHashCode() => BeatmapSet.GetHashCode(); } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs index fdb43cb47e..1b15b2498c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs @@ -17,8 +17,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { public class DownloadButton : BeatmapCardIconButton { - public IBindable State => state; - private readonly Bindable state = new Bindable(); + public Bindable State { get; } = new Bindable(); private readonly APIBeatmapSet beatmapSet; @@ -48,14 +47,19 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { base.LoadComplete(); preferNoVideo.BindValueChanged(_ => updateState()); - state.BindValueChanged(_ => updateState(), true); + State.BindValueChanged(_ => updateState(), true); FinishTransforms(true); } private void updateState() { - switch (state.Value) + switch (State.Value) { + case DownloadState.Unknown: + Action = null; + TooltipText = string.Empty; + break; + case DownloadState.Downloading: case DownloadState.Importing: Action = null; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs index 18eab09465..c5436182a4 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs @@ -41,6 +41,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons Anchor = Origin = Anchor.Centre; + // needed for touch input to work when card is not hovered/expanded + AlwaysPresent = true; + Children = new Drawable[] { icon = new SpriteIcon @@ -118,7 +121,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons // another async load might have completed before this one. // if so, do not make any changes. if (loadedPreview != previewTrack) + { + loadedPreview.Dispose(); return; + } AddInternal(loadedPreview); toggleLoading(false); diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index 44bccd69d0..9585f1bdb5 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -151,7 +151,7 @@ namespace osu.Game.Beatmaps.Drawables displayedStars.BindValueChanged(s => { - starsText.Text = s.NewValue.ToLocalisableString("0.00"); + starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.ToLocalisableString("0.00"); background.Colour = colours.ForStarDifficulty(s.NewValue); diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 9610dbcc78..0b390a2ab5 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -44,6 +44,10 @@ namespace osu.Game.Beatmaps }, audio) { this.textures = textures; + + // We are guaranteed to have a virtual track. + // To ease usability, ensure the track is available from point of construction. + LoadTrack(); } protected override IBeatmap GetBeatmap() => new Beatmap(); diff --git a/osu.Game/Beatmaps/EFBeatmapDifficulty.cs b/osu.Game/Beatmaps/EFBeatmapDifficulty.cs deleted file mode 100644 index 8bcac24ca1..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapDifficulty.cs +++ /dev/null @@ -1,80 +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 System.ComponentModel.DataAnnotations.Schema; -using osu.Game.Database; - -namespace osu.Game.Beatmaps -{ - [Table(@"BeatmapDifficulty")] - public class EFBeatmapDifficulty : IHasPrimaryKey, IBeatmapDifficultyInfo - { - /// - /// The default value used for all difficulty settings except and . - /// - public const float DEFAULT_DIFFICULTY = 5; - - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public float DrainRate { get; set; } = DEFAULT_DIFFICULTY; - public float CircleSize { get; set; } = DEFAULT_DIFFICULTY; - public float OverallDifficulty { get; set; } = DEFAULT_DIFFICULTY; - - private float? approachRate; - - public EFBeatmapDifficulty() - { - } - - public EFBeatmapDifficulty(IBeatmapDifficultyInfo source) - { - CopyFrom(source); - } - - public float ApproachRate - { - get => approachRate ?? OverallDifficulty; - set => approachRate = value; - } - - public double SliderMultiplier { get; set; } = 1; - public double SliderTickRate { get; set; } = 1; - - /// - /// Returns a shallow-clone of this . - /// - public EFBeatmapDifficulty Clone() - { - var diff = (EFBeatmapDifficulty)Activator.CreateInstance(GetType()); - CopyTo(diff); - return diff; - } - - public virtual void CopyFrom(IBeatmapDifficultyInfo other) - { - ApproachRate = other.ApproachRate; - DrainRate = other.DrainRate; - CircleSize = other.CircleSize; - OverallDifficulty = other.OverallDifficulty; - - SliderMultiplier = other.SliderMultiplier; - SliderTickRate = other.SliderTickRate; - } - - public virtual void CopyTo(EFBeatmapDifficulty other) - { - other.ApproachRate = ApproachRate; - other.DrainRate = DrainRate; - other.CircleSize = CircleSize; - other.OverallDifficulty = OverallDifficulty; - - other.SliderMultiplier = SliderMultiplier; - other.SliderTickRate = SliderTickRate; - } - } -} diff --git a/osu.Game/Beatmaps/EFBeatmapInfo.cs b/osu.Game/Beatmaps/EFBeatmapInfo.cs deleted file mode 100644 index 20abdc686a..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapInfo.cs +++ /dev/null @@ -1,183 +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 System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using Newtonsoft.Json; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Scoring; - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - [Serializable] - [Table(@"BeatmapInfo")] - public class EFBeatmapInfo : IEquatable, IHasPrimaryKey, IBeatmapInfo - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int BeatmapVersion; - - private int? onlineID; - - [JsonProperty("id")] - [Column("OnlineBeatmapID")] - public int? OnlineID - { - get => onlineID; - set => onlineID = value > 0 ? value : null; - } - - [JsonIgnore] - public int BeatmapSetInfoID { get; set; } - - public BeatmapOnlineStatus Status { get; set; } = BeatmapOnlineStatus.None; - - [Required] - public EFBeatmapSetInfo BeatmapSetInfo { get; set; } - - public EFBeatmapMetadata Metadata { get; set; } - - [JsonIgnore] - public int BaseDifficultyID { get; set; } - - public EFBeatmapDifficulty BaseDifficulty { get; set; } - - [NotMapped] - public APIBeatmap OnlineInfo { get; set; } - - /// - /// The playable length in milliseconds of this beatmap. - /// - public double Length { get; set; } - - /// - /// The most common BPM of this beatmap. - /// - public double BPM { get; set; } - - public string Path { get; set; } - - [JsonProperty("file_sha2")] - public string Hash { get; set; } - - [JsonIgnore] - public bool Hidden { get; set; } - - /// - /// MD5 is kept for legacy support (matching against replays, osu-web-10 etc.). - /// - [JsonProperty("file_md5")] - public string MD5Hash { get; set; } - - // General - public double AudioLeadIn { get; set; } - public float StackLeniency { get; set; } = 0.7f; - public bool SpecialStyle { get; set; } - - [Column("RulesetID")] - public int RulesetInfoID { get; set; } - - public EFRulesetInfo RulesetInfo { get; set; } - - public bool LetterboxInBreaks { get; set; } - public bool WidescreenStoryboard { get; set; } - public bool EpilepsyWarning { get; set; } - - /// - /// Whether or not sound samples should change rate when playing with speed-changing mods. - /// TODO: only read/write supported for now, requires implementation in gameplay. - /// - public bool SamplesMatchPlaybackRate { get; set; } - - public CountdownType Countdown { get; set; } = CountdownType.Normal; - - /// - /// The number of beats to move the countdown backwards (compared to its default location). - /// - public int CountdownOffset { get; set; } - - [NotMapped] - public int[] Bookmarks { get; set; } = Array.Empty(); - - public double DistanceSpacing { get; set; } - public int BeatDivisor { get; set; } - public int GridSize { get; set; } - public double TimelineZoom { get; set; } - - // Metadata - [Column("Version")] - public string DifficultyName { get; set; } - - [JsonProperty("difficulty_rating")] - [Column("StarDifficulty")] - public double StarRating { get; set; } - - /// - /// Currently only populated for beatmap deletion. Use to query scores. - /// - public List Scores { get; set; } - - [JsonIgnore] - public DifficultyRating DifficultyRating => StarDifficulty.GetDifficultyRating(StarRating); - - public override string ToString() => this.GetDisplayTitle(); - - public bool Equals(EFBeatmapInfo other) - { - if (ReferenceEquals(this, other)) return true; - if (other == null) return false; - - if (ID != 0 && other.ID != 0) - return ID == other.ID; - - return false; - } - - public bool Equals(IBeatmapInfo other) => other is EFBeatmapInfo b && Equals(b); - - public bool AudioEquals(EFBeatmapInfo other) => other != null && BeatmapSetInfo != null && other.BeatmapSetInfo != null && - BeatmapSetInfo.Hash == other.BeatmapSetInfo.Hash && - (Metadata ?? BeatmapSetInfo.Metadata).AudioFile == (other.Metadata ?? other.BeatmapSetInfo.Metadata).AudioFile; - - public bool BackgroundEquals(EFBeatmapInfo other) => other != null && BeatmapSetInfo != null && other.BeatmapSetInfo != null && - BeatmapSetInfo.Hash == other.BeatmapSetInfo.Hash && - (Metadata ?? BeatmapSetInfo.Metadata).BackgroundFile == (other.Metadata ?? other.BeatmapSetInfo.Metadata).BackgroundFile; - - /// - /// Returns a shallow-clone of this . - /// - public EFBeatmapInfo Clone() => (EFBeatmapInfo)MemberwiseClone(); - - #region Implementation of IHasOnlineID - - int IHasOnlineID.OnlineID => OnlineID ?? -1; - - #endregion - - #region Implementation of IBeatmapInfo - - [JsonIgnore] - IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata ?? BeatmapSetInfo?.Metadata ?? new EFBeatmapMetadata(); - - [JsonIgnore] - IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => BaseDifficulty; - - [JsonIgnore] - IBeatmapSetInfo IBeatmapInfo.BeatmapSet => BeatmapSetInfo; - - [JsonIgnore] - IRulesetInfo IBeatmapInfo.Ruleset => RulesetInfo; - - #endregion - } -} diff --git a/osu.Game/Beatmaps/EFBeatmapMetadata.cs b/osu.Game/Beatmaps/EFBeatmapMetadata.cs deleted file mode 100644 index c0588f128c..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapMetadata.cs +++ /dev/null @@ -1,89 +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 System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using Newtonsoft.Json; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Users; - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - [Serializable] - [Table(@"BeatmapMetadata")] - public class EFBeatmapMetadata : IEquatable, IHasPrimaryKey, IBeatmapMetadataInfo - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public string Title { get; set; } = string.Empty; - - [JsonProperty("title_unicode")] - public string TitleUnicode { get; set; } = string.Empty; - - public string Artist { get; set; } = string.Empty; - - [JsonProperty("artist_unicode")] - public string ArtistUnicode { get; set; } = string.Empty; - - [JsonIgnore] - public List Beatmaps { get; set; } = new List(); - - [JsonIgnore] - public List BeatmapSets { get; set; } = new List(); - - /// - /// The author of the beatmaps in this set. - /// - [JsonIgnore] - public APIUser Author = new APIUser(); - - /// - /// Helper property to deserialize a username to . - /// - [JsonProperty(@"user_id")] - [Column("AuthorID")] - public int AuthorID - { - get => Author.Id; // This should not be used, but is required to make EF work correctly. - set => Author.Id = value; - } - - /// - /// Helper property to deserialize a username to . - /// - [JsonProperty(@"creator")] - [Column("Author")] - public string AuthorString - { - get => Author.Username; // This should not be used, but is required to make EF work correctly. - set => Author.Username = value; - } - - public string Source { get; set; } = string.Empty; - - [JsonProperty(@"tags")] - public string Tags { get; set; } = string.Empty; - - /// - /// The time in milliseconds to begin playing the track for preview purposes. - /// If -1, the track should begin playing at 40% of its length. - /// - public int PreviewTime { get; set; } = -1; - - public string AudioFile { get; set; } = string.Empty; - - public string BackgroundFile { get; set; } = string.Empty; - - public bool Equals(EFBeatmapMetadata other) => ((IBeatmapMetadataInfo)this).Equals(other); - - public override string ToString() => this.GetDisplayTitle(); - - IUser IBeatmapMetadataInfo.Author => Author; - } -} diff --git a/osu.Game/Beatmaps/EFBeatmapSetInfo.cs b/osu.Game/Beatmaps/EFBeatmapSetInfo.cs deleted file mode 100644 index 1f41d3727c..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapSetInfo.cs +++ /dev/null @@ -1,108 +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 System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using JetBrains.Annotations; -using Newtonsoft.Json; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Extensions; - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - [Serializable] - [Table(@"BeatmapSetInfo")] - public class EFBeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - private int? onlineID; - - [Column("OnlineBeatmapSetID")] - public int? OnlineID - { - get => onlineID; - set => onlineID = value > 0 ? value : null; - } - - public DateTimeOffset DateAdded { get; set; } - - public EFBeatmapMetadata Metadata { get; set; } - - [NotNull] - public List Beatmaps { get; } = new List(); - - public BeatmapOnlineStatus Status { get; set; } = BeatmapOnlineStatus.None; - - public List Files { get; } = new List(); - - /// - /// The maximum star difficulty of all beatmaps in this set. - /// - [JsonIgnore] - public double MaxStarDifficulty => Beatmaps.Count == 0 ? 0 : Beatmaps.Max(b => b.StarRating); - - /// - /// The maximum playable length in milliseconds of all beatmaps in this set. - /// - [JsonIgnore] - public double MaxLength => Beatmaps.Count == 0 ? 0 : Beatmaps.Max(b => b.Length); - - /// - /// The maximum BPM of all beatmaps in this set. - /// - [JsonIgnore] - public double MaxBPM => Beatmaps.Count == 0 ? 0 : Beatmaps.Max(b => b.BPM); - - [NotMapped] - public bool DeletePending { get; set; } - - public string Hash { get; set; } - - /// - /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. - /// The path returned is relative to the user file storage. - /// - /// The name of the file to get the storage path of. - public string GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath(); - - public override string ToString() => Metadata?.ToString() ?? base.ToString(); - - public bool Protected { get; set; } - - public bool Equals(EFBeatmapSetInfo other) - { - if (ReferenceEquals(this, other)) return true; - if (other == null) return false; - - if (ID != 0 && other.ID != 0) - return ID == other.ID; - - return false; - } - - public bool Equals(IBeatmapSetInfo other) => other is EFBeatmapSetInfo b && Equals(b); - - #region Implementation of IHasOnlineID - - int IHasOnlineID.OnlineID => OnlineID ?? -1; - - #endregion - - #region Implementation of IBeatmapSetInfo - - IBeatmapMetadataInfo IBeatmapSetInfo.Metadata => Metadata ?? Beatmaps.FirstOrDefault()?.Metadata ?? new EFBeatmapMetadata(); - IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; - IEnumerable IHasNamedFiles.Files => Files; - - #endregion - } -} diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 89d3465ab6..5f5749dc73 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -355,6 +355,14 @@ namespace osu.Game.Beatmaps.Formats switch (type) { + case LegacyEventType.Sprite: + // Generally, the background is the first thing defined in a beatmap file. + // In some older beatmaps, it is not present and replaced by a storyboard-level background instead. + // Allow the first sprite (by file order) to act as the background in such cases. + if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]); + break; + case LegacyEventType.Background: beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); break; @@ -373,7 +381,11 @@ namespace osu.Game.Beatmaps.Formats string[] split = line.Split(','); double time = getOffsetTime(Parsing.ParseDouble(split[0].Trim())); - double beatLength = Parsing.ParseDouble(split[1].Trim()); + + // beatLength is allowed to be NaN to handle an edge case in which some beatmaps use NaN slider velocity to disable slider tick generation (see LegacyDifficultyControlPoint). + double beatLength = Parsing.ParseDouble(split[1].Trim(), allowNaN: true); + + // If beatLength is NaN, speedMultiplier should still be 1 because all comparisons against NaN are false. double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; TimeSignature timeSignature = TimeSignature.SimpleQuadruple; @@ -412,6 +424,9 @@ namespace osu.Game.Beatmaps.Formats if (timingChange) { + if (double.IsNaN(beatLength)) + throw new InvalidDataException("Beat length cannot be NaN in a timing control point"); + var controlPoint = CreateTimingControlPoint(); controlPoint.BeatLength = beatLength; diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 52e760a068..9c066ada08 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -79,7 +79,7 @@ namespace osu.Game.Beatmaps.Formats switch (section) { case Section.Colours: - HandleColours(output, line); + HandleColours(output, line, false); return; } } @@ -93,7 +93,7 @@ namespace osu.Game.Beatmaps.Formats return line; } - protected void HandleColours(TModel output, string line) + protected void HandleColours(TModel output, string line, bool allowAlpha) { var pair = SplitKeyVal(line); @@ -108,7 +108,7 @@ namespace osu.Game.Beatmaps.Formats try { - byte alpha = split.Length == 4 ? byte.Parse(split[3]) : (byte)255; + byte alpha = allowAlpha && split.Length == 4 ? byte.Parse(split[3]) : (byte)255; colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), alpha); } catch @@ -168,11 +168,18 @@ namespace osu.Game.Beatmaps.Formats /// public double BpmMultiplier { get; private set; } + /// + /// Whether or not slider ticks should be generated at this control point. + /// This exists for backwards compatibility with maps that abuse NaN slider velocity behavior on osu!stable (e.g. /b/2628991). + /// + public bool GenerateTicks { get; private set; } = true; + public LegacyDifficultyControlPoint(double beatLength) : this() { // Note: In stable, the division occurs on floats, but with compiler optimisations turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?). BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100.0 : 1; + GenerateTicks = !double.IsNaN(beatLength); } public LegacyDifficultyControlPoint() @@ -180,11 +187,16 @@ namespace osu.Game.Beatmaps.Formats SliderVelocityBindable.Precision = double.Epsilon; } + public override bool IsRedundant(ControlPoint? existing) + => base.IsRedundant(existing) + && GenerateTicks == ((existing as LegacyDifficultyControlPoint)?.GenerateTicks ?? true); + public override void CopyFrom(ControlPoint other) { base.CopyFrom(other); BpmMultiplier = ((LegacyDifficultyControlPoint)other).BpmMultiplier; + GenerateTicks = ((LegacyDifficultyControlPoint)other).GenerateTicks; } public override bool Equals(ControlPoint? other) @@ -193,10 +205,11 @@ namespace osu.Game.Beatmaps.Formats public bool Equals(LegacyDifficultyControlPoint? other) => base.Equals(other) - && BpmMultiplier == other.BpmMultiplier; + && BpmMultiplier == other.BpmMultiplier + && GenerateTicks == other.GenerateTicks; - // ReSharper disable once NonReadonlyMemberInGetHashCode - public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), BpmMultiplier); + // ReSharper disable twice NonReadonlyMemberInGetHashCode + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), BpmMultiplier, GenerateTicks); } internal class LegacySampleControlPoint : SampleControlPoint, IEquatable diff --git a/osu.Game/Beatmaps/Formats/Parsing.cs b/osu.Game/Beatmaps/Formats/Parsing.cs index ce04f27020..9b0d200077 100644 --- a/osu.Game/Beatmaps/Formats/Parsing.cs +++ b/osu.Game/Beatmaps/Formats/Parsing.cs @@ -17,26 +17,26 @@ namespace osu.Game.Beatmaps.Formats public const double MAX_PARSE_VALUE = int.MaxValue; - public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE) + public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE, bool allowNaN = false) { float output = float.Parse(input, CultureInfo.InvariantCulture); if (output < -parseLimit) throw new OverflowException("Value is too low"); if (output > parseLimit) throw new OverflowException("Value is too high"); - if (float.IsNaN(output)) throw new FormatException("Not a number"); + if (!allowNaN && float.IsNaN(output)) throw new FormatException("Not a number"); return output; } - public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE) + public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE, bool allowNaN = false) { double output = double.Parse(input, CultureInfo.InvariantCulture); if (output < -parseLimit) throw new OverflowException("Value is too low"); if (output > parseLimit) throw new OverflowException("Value is too high"); - if (double.IsNaN(output)) throw new FormatException("Not a number"); + if (!allowNaN && double.IsNaN(output)) throw new FormatException("Not a number"); return output; } diff --git a/osu.Game/Beatmaps/FramedBeatmapClock.cs b/osu.Game/Beatmaps/FramedBeatmapClock.cs new file mode 100644 index 0000000000..c7050cc50f --- /dev/null +++ b/osu.Game/Beatmaps/FramedBeatmapClock.cs @@ -0,0 +1,224 @@ +// 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.Diagnostics; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Timing; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Screens.Play; + +namespace osu.Game.Beatmaps +{ + /// + /// A clock intended to be the single source-of-truth for beatmap timing. + /// + /// It provides some functionality: + /// - Optionally applies (and tracks changes of) beatmap, user, and platform offsets (see ctor argument applyOffsets). + /// - Adjusts operations to account for any applied offsets, seeking in raw "beatmap" time values. + /// - Exposes track length. + /// - Allows changing the source to a new track (for cases like editor track updating). + /// + public class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock + { + private readonly bool applyOffsets; + + /// + /// The length of the underlying beatmap track. Will default to 60 seconds if unavailable. + /// + public double TrackLength => Track.Length; + + /// + /// The underlying beatmap track, if available. + /// + public Track Track { get; private set; } = new TrackVirtual(60000); + + /// + /// The total frequency adjustment from pause transforms. Should eventually be handled in a better way. + /// + public readonly BindableDouble ExternalPauseFrequencyAdjust = new BindableDouble(1); + + private readonly OffsetCorrectionClock? userGlobalOffsetClock; + private readonly OffsetCorrectionClock? platformOffsetClock; + private readonly OffsetCorrectionClock? userBeatmapOffsetClock; + + private readonly IFrameBasedClock finalClockSource; + + private Bindable? userAudioOffset; + + private IDisposable? beatmapOffsetSubscription; + + private readonly DecoupleableInterpolatingFramedClock decoupledClock; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public bool IsCoupled + { + get => decoupledClock.IsCoupled; + set => decoupledClock.IsCoupled = value; + } + + public FramedBeatmapClock(bool applyOffsets = false) + { + this.applyOffsets = applyOffsets; + + // A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting + // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example). + decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; + + if (applyOffsets) + { + // Audio timings in general with newer BASS versions don't match stable. + // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. + platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + + // User global offset (set in settings) should also be applied. + userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust); + + // User per-beatmap offset will be applied to this final clock. + finalClockSource = userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, ExternalPauseFrequencyAdjust); + } + else + { + finalClockSource = decoupledClock; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (applyOffsets) + { + Debug.Assert(userBeatmapOffsetClock != null); + Debug.Assert(userGlobalOffsetClock != null); + + userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); + userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); + + beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( + r => r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings, + settings => settings.Offset, + val => + { + userBeatmapOffsetClock.Offset = val; + }); + } + } + + protected override void Update() + { + base.Update(); + + if (Source != null && Source is not IAdjustableClock && Source.CurrentTime < decoupledClock.CurrentTime) + { + // InterpolatingFramedClock won't interpolate backwards unless its source has an ElapsedFrameTime. + // See https://github.com/ppy/osu-framework/blob/ba1385330cc501f34937e08257e586c84e35d772/osu.Framework/Timing/InterpolatingFramedClock.cs#L91-L93 + // This is not always the case here when doing large seeks. + // (Of note, this is not an issue if the source is adjustable, as the source is seeked to be in time by DecoupleableInterpolatingFramedClock). + // Rather than trying to get around this by fixing the framework clock stack, let's work around it for now. + Seek(Source.CurrentTime); + } + else + finalClockSource.ProcessFrame(); + } + + public double TotalAppliedOffset + { + get + { + if (!applyOffsets) + return 0; + + Debug.Assert(userGlobalOffsetClock != null); + Debug.Assert(userBeatmapOffsetClock != null); + Debug.Assert(platformOffsetClock != null); + + return userGlobalOffsetClock.RateAdjustedOffset + userBeatmapOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; + } + } + + #region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock. + + public void ChangeSource(IClock? source) + { + Track = source as Track ?? new TrackVirtual(60000); + decoupledClock.ChangeSource(source); + } + + public IClock? Source => decoupledClock.Source; + + public void Reset() + { + decoupledClock.Reset(); + finalClockSource.ProcessFrame(); + } + + public void Start() + { + decoupledClock.Start(); + finalClockSource.ProcessFrame(); + } + + public void Stop() + { + decoupledClock.Stop(); + finalClockSource.ProcessFrame(); + } + + public bool Seek(double position) + { + bool success = decoupledClock.Seek(position - TotalAppliedOffset); + finalClockSource.ProcessFrame(); + + return success; + } + + public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments(); + + public double Rate + { + get => decoupledClock.Rate; + set => decoupledClock.Rate = value; + } + + #endregion + + #region Delegation of IFrameBasedClock to clock with all offsets applied + + public double CurrentTime => finalClockSource.CurrentTime; + + public bool IsRunning => finalClockSource.IsRunning; + + public void ProcessFrame() + { + // Noop to ensure an external consumer doesn't process the internal clock an extra time. + } + + public double ElapsedFrameTime => finalClockSource.ElapsedFrameTime; + + public double FramesPerSecond => finalClockSource.FramesPerSecond; + + public FrameTimeInfo TimeInfo => finalClockSource.TimeInfo; + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + beatmapOffsetSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Beatmaps/IBeatSyncProvider.cs b/osu.Game/Beatmaps/IBeatSyncProvider.cs index 362f02f2dd..9ee19e720d 100644 --- a/osu.Game/Beatmaps/IBeatSyncProvider.cs +++ b/osu.Game/Beatmaps/IBeatSyncProvider.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Audio.Track; +using osu.Framework.Audio; using osu.Framework.Timing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; @@ -14,12 +14,16 @@ namespace osu.Game.Beatmaps /// Primarily intended for use with . /// [Cached] - public interface IBeatSyncProvider + public interface IBeatSyncProvider : IHasAmplitudes { + /// + /// Access any available control points from a beatmap providing beat sync. If null, no current provider is available. + /// ControlPointInfo? ControlPoints { get; } + /// + /// Access a clock currently responsible for providing beat sync. If null, no current provider is available. + /// IClock? Clock { get; } - - ChannelAmplitudes? Amplitudes { get; } } } diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 25b147c267..0e892b6581 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -11,6 +11,10 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Beatmaps { + /// + /// A materialised beatmap. + /// Generally this interface will be implemented alongside , which exposes the ruleset-typed hit objects. + /// public interface IBeatmap { /// @@ -65,6 +69,9 @@ namespace osu.Game.Beatmaps IBeatmap Clone(); } + /// + /// A materialised beatmap containing converted HitObjects. + /// public interface IBeatmap : IBeatmap where T : HitObject { diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index 2188bd6a2b..0f0e72b0ac 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -18,7 +18,12 @@ using osu.Game.Storyboards; namespace osu.Game.Beatmaps { /// - /// Provides access to the multiple resources offered by a beatmap model (textures, skins, playable beatmaps etc.) + /// A more expensive representation of a beatmap which allows access to various associated resources. + /// - Access textures and other resources via . + /// - Access the storyboard via . + /// - Access a local skin via . + /// - Access the track via (and then for subsequent accesses). + /// - Create a playable via . /// public interface IWorkingBeatmap { @@ -129,6 +134,6 @@ namespace osu.Game.Beatmaps /// /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. /// - void PrepareTrackForPreviewLooping(); + void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0); } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 16464932e0..393c4ba892 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -110,9 +110,9 @@ namespace osu.Game.Beatmaps public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000); - public void PrepareTrackForPreviewLooping() + public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0) { - Track.Looping = true; + Track.Looping = looping; Track.RestartPoint = Metadata.PreviewTime; if (Track.RestartPoint == -1) @@ -125,6 +125,8 @@ namespace osu.Game.Beatmaps Track.RestartPoint = 0.4f * Track.Length; } + + Track.RestartPoint += offsetFromPreviewPoint; } /// @@ -146,6 +148,7 @@ namespace osu.Game.Beatmaps /// Get the loaded audio track instance. must have first been called. /// This generally happens via MusicController when changing the global beatmap. /// + [NotNull] public Track Track { get @@ -279,12 +282,15 @@ namespace osu.Game.Beatmaps } } - IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); + var processor = rulesetInstance.CreateBeatmapProcessor(converted); - foreach (var mod in mods.OfType()) - mod.ApplyToBeatmapProcessor(processor); + if (processor != null) + { + foreach (var mod in mods.OfType()) + mod.ApplyToBeatmapProcessor(processor); - processor?.PreProcess(); + processor.PreProcess(); + } // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed foreach (var obj in converted.HitObjects) diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index ce883a7092..adb5f8c433 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -9,6 +9,8 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Lists; @@ -56,7 +58,7 @@ namespace osu.Game.Beatmaps this.resources = resources; this.host = host; this.files = files; - largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files)); + largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files)); this.trackStore = trackStore; } @@ -110,6 +112,7 @@ namespace osu.Game.Beatmaps TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; ITrackStore IBeatmapResourceProvider.Tracks => trackStore; + IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer(); AudioManager IStorageResourceProvider.AudioManager => audioManager; RealmAccess IStorageResourceProvider.RealmAccess => null; IResourceStore IStorageResourceProvider.Files => files; @@ -168,7 +171,7 @@ namespace osu.Game.Beatmaps if (texture == null) { - Logger.Log($"Beatmap background failed to load (file {Metadata.BackgroundFile} not found on disk at expected location {fileStorePath}).", level: LogLevel.Error); + Logger.Log($"Beatmap background failed to load (file {Metadata.BackgroundFile} not found on disk at expected location {fileStorePath})."); return null; } diff --git a/osu.Game/Collections/BeatmapCollection.cs b/osu.Game/Collections/BeatmapCollection.cs index 742d757bec..ca5f8dbe53 100644 --- a/osu.Game/Collections/BeatmapCollection.cs +++ b/osu.Game/Collections/BeatmapCollection.cs @@ -1,49 +1,57 @@ // 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 System.Collections.Generic; +using JetBrains.Annotations; using osu.Game.Beatmaps; +using osu.Game.Database; +using Realms; namespace osu.Game.Collections { /// /// A collection of beatmaps grouped by a name. /// - public class BeatmapCollection + public class BeatmapCollection : RealmObject, IHasGuidPrimaryKey { - /// - /// Invoked whenever any change occurs on this . - /// - public event Action Changed; + [PrimaryKey] + public Guid ID { get; set; } /// /// The collection's name. /// - public readonly Bindable Name = new Bindable(); + public string Name { get; set; } = string.Empty; /// /// The es of beatmaps contained by the collection. /// - public readonly BindableList BeatmapHashes = new BindableList(); + /// + /// We store as hashes rather than references to s to allow collections to maintain + /// references to beatmaps even if they are removed. This helps with cases like importing collections before + /// importing the beatmaps they contain, or when sharing collections between users. + /// + /// This can probably change in the future as we build the system up. + /// + public IList BeatmapMD5Hashes { get; } = null!; /// /// The date when this collection was last modified. /// - public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastModified { get; set; } - public BeatmapCollection() + public BeatmapCollection(string? name = null, IList? beatmapMD5Hashes = null) { - BeatmapHashes.CollectionChanged += (_, _) => onChange(); - Name.ValueChanged += _ => onChange(); + ID = Guid.NewGuid(); + Name = name ?? string.Empty; + BeatmapMD5Hashes = beatmapMD5Hashes ?? new List(); + + LastModified = DateTimeOffset.UtcNow; } - private void onChange() + [UsedImplicitly] + private BeatmapCollection() { - LastModifyDate = DateTimeOffset.Now; - Changed?.Invoke(); } } } diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs new file mode 100644 index 0000000000..71341f51a4 --- /dev/null +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -0,0 +1,257 @@ +// 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.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK; +using Realms; + +namespace osu.Game.Collections +{ + /// + /// A dropdown to select the collection to be used to filter results. + /// + public class CollectionDropdown : OsuDropdown + { + /// + /// Whether to show the "manage collections..." menu item in the dropdown. + /// + protected virtual bool ShowManageCollectionsItem => true; + + public Action? RequestFilter { private get; set; } + + private readonly BindableList filters = new BindableList(); + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IDisposable? realmSubscription; + + public CollectionDropdown() + { + ItemSource = filters; + + Current.Value = new AllBeatmapsCollectionFilterMenuItem(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); + + Current.BindValueChanged(selectionChanged); + } + + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes, Exception error) + { + var selectedItem = SelectedItem?.Value?.Collection; + + var allBeatmaps = new AllBeatmapsCollectionFilterMenuItem(); + + filters.Clear(); + filters.Add(allBeatmaps); + filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); + + if (ShowManageCollectionsItem) + filters.Add(new ManageCollectionsFilterMenuItem()); + + // This current update and schedule is required to work around dropdown headers not updating text even when the selected item + // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue + // a warning that it's going to be a frustrating journey. + Current.Value = allBeatmaps; + Schedule(() => + { + // current may have changed before the scheduled call is run. + if (Current.Value != allBeatmaps) + return; + + Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection.ID == selectedItem?.ID) ?? filters[0]; + }); + + // Trigger a re-filter if the current item was in the change set. + if (selectedItem != null && changes != null) + { + foreach (int index in changes.ModifiedIndices) + { + if (collections[index].ID == selectedItem.ID) + RequestFilter?.Invoke(); + } + } + } + + private Live? lastFiltered; + + private void selectionChanged(ValueChangedEvent filter) + { + // May be null during .Clear(). + if (filter.NewValue == null) + return; + + // Never select the manage collection filter - rollback to the previous filter. + // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. + if (filter.NewValue is ManageCollectionsFilterMenuItem) + { + Current.Value = filter.OldValue; + manageCollectionsDialog?.Show(); + return; + } + + var newCollection = filter.NewValue?.Collection; + + // This dropdown be weird. + // We only care about filtering if the actual collection has changed. + if (newCollection != lastFiltered) + { + RequestFilter?.Invoke(); + lastFiltered = newCollection; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + + protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName; + + protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader(); + + protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); + + protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); + + protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); + + public class CollectionDropdownHeader : OsuDropdownHeader + { + public CollectionDropdownHeader() + { + Height = 25; + Icon.Size = new Vector2(16); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; + } + } + + protected class CollectionDropdownMenu : OsuDropdownMenu + { + public CollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + } + + protected class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem + { + private IconButton addOrRemoveButton = null!; + + private bool beatmapInCollection; + + private readonly Live? collection; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public CollectionDropdownDrawableMenuItem(MenuItem item) + : base(item) + { + collection = ((DropdownMenuItem)item).Value.Collection; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new IconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collection != null) + { + beatmap.BindValueChanged(_ => + { + beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash)); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; + + updateButtonVisibility(); + }, true); + } + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collection == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collection != null); + + collection.PerformWrite(c => + { + if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) + c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); + }); + } + + protected override Drawable CreateContent() => (Content)base.CreateContent(); + } + } +} diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs deleted file mode 100644 index d099eb6e1b..0000000000 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ /dev/null @@ -1,297 +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.Specialized; -using System.Diagnostics; -using System.Linq; -using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osuTK; - -namespace osu.Game.Collections -{ - /// - /// A dropdown to select the to filter beatmaps using. - /// - public class CollectionFilterDropdown : OsuDropdown - { - /// - /// Whether to show the "manage collections..." menu item in the dropdown. - /// - protected virtual bool ShowManageCollectionsItem => true; - - private readonly BindableWithCurrent current = new BindableWithCurrent(); - - public new Bindable Current - { - get => current.Current; - set => current.Current = value; - } - - private readonly IBindableList collections = new BindableList(); - private readonly IBindableList beatmaps = new BindableList(); - private readonly BindableList filters = new BindableList(); - - [Resolved(CanBeNull = true)] - private ManageCollectionsDialog manageCollectionsDialog { get; set; } - - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } - - public CollectionFilterDropdown() - { - ItemSource = filters; - Current.Value = new AllBeatmapsCollectionFilterMenuItem(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (collectionManager != null) - collections.BindTo(collectionManager.Collections); - - // Dropdown has logic which triggers a change on the bindable with every change to the contained items. - // This is not desirable here, as it leads to multiple filter operations running even though nothing has changed. - // An extra bindable is enough to subvert this behaviour. - base.Current = Current; - - collections.BindCollectionChanged((_, _) => collectionsChanged(), true); - Current.BindValueChanged(filterChanged, true); - } - - /// - /// Occurs when a collection has been added or removed. - /// - private void collectionsChanged() - { - var selectedItem = SelectedItem?.Value?.Collection; - - filters.Clear(); - filters.Add(new AllBeatmapsCollectionFilterMenuItem()); - filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c))); - - if (ShowManageCollectionsItem) - filters.Add(new ManageCollectionsFilterMenuItem()); - - Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection == selectedItem) ?? filters[0]; - } - - /// - /// Occurs when the selection has changed. - /// - private void filterChanged(ValueChangedEvent filter) - { - // Binding the beatmaps will trigger a collection change event, which results in an infinite-loop. This is rebound later, when it's safe to do so. - beatmaps.CollectionChanged -= filterBeatmapsChanged; - - if (filter.OldValue?.Collection != null) - beatmaps.UnbindFrom(filter.OldValue.Collection.BeatmapHashes); - - if (filter.NewValue?.Collection != null) - beatmaps.BindTo(filter.NewValue.Collection.BeatmapHashes); - - beatmaps.CollectionChanged += filterBeatmapsChanged; - - // Never select the manage collection filter - rollback to the previous filter. - // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. - if (filter.NewValue is ManageCollectionsFilterMenuItem) - { - Current.Value = filter.OldValue; - manageCollectionsDialog?.Show(); - } - } - - /// - /// Occurs when the beatmaps contained by a have changed. - /// - private void filterBeatmapsChanged(object sender, NotifyCollectionChangedEventArgs e) - { - // The filtered beatmaps have changed, without the filter having changed itself. So a change in filter must be notified. - // Note that this does NOT propagate to bound bindables, so the FilterControl must bind directly to the value change event of this bindable. - Current.TriggerChange(); - } - - protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; - - protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader().With(d => - { - d.SelectedItem.BindTarget = Current; - }); - - protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); - - protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); - - protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); - - public class CollectionDropdownHeader : OsuDropdownHeader - { - public readonly Bindable SelectedItem = new Bindable(); - private readonly Bindable collectionName = new Bindable(); - - protected override LocalisableString Label - { - get => base.Label; - set { } // See updateText(). - } - - public CollectionDropdownHeader() - { - Height = 25; - Icon.Size = new Vector2(16); - Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - SelectedItem.BindValueChanged(_ => updateBindable(), true); - } - - private void updateBindable() - { - collectionName.UnbindAll(); - - if (SelectedItem.Value != null) - collectionName.BindTo(SelectedItem.Value.CollectionName); - - collectionName.BindValueChanged(_ => updateText(), true); - } - - // Dropdowns don't bind to value changes, so the real name is copied directly from the selected item here. - private void updateText() => base.Label = collectionName.Value; - } - - protected class CollectionDropdownMenu : OsuDropdownMenu - { - public CollectionDropdownMenu() - { - MaxHeight = 200; - } - - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item) - { - BackgroundColourHover = HoverColour, - BackgroundColourSelected = SelectionColour - }; - } - - protected class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem - { - [NotNull] - protected new CollectionFilterMenuItem Item => ((DropdownMenuItem)base.Item).Value; - - [Resolved] - private IBindable beatmap { get; set; } - - [CanBeNull] - private readonly BindableList collectionBeatmaps; - - [NotNull] - private readonly Bindable collectionName; - - private IconButton addOrRemoveButton; - private Content content; - private bool beatmapInCollection; - - public CollectionDropdownMenuItem(MenuItem item) - : base(item) - { - collectionBeatmaps = Item.Collection?.BeatmapHashes.GetBoundCopy(); - collectionName = Item.CollectionName.GetBoundCopy(); - } - - [BackgroundDependencyLoader] - private void load() - { - AddInternal(addOrRemoveButton = new IconButton - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, - Scale = new Vector2(0.65f), - Action = addOrRemove, - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (collectionBeatmaps != null) - { - collectionBeatmaps.CollectionChanged += (_, _) => collectionChanged(); - beatmap.BindValueChanged(_ => collectionChanged(), true); - } - - // Although the DrawableMenuItem binds to value changes of the item's text, the item is an internal implementation detail of Dropdown that has no knowledge - // of the underlying CollectionFilter value and its accompanying name, so the real name has to be copied here. Without this, the collection name wouldn't update when changed. - collectionName.BindValueChanged(name => content.Text = name.NewValue, true); - - updateButtonVisibility(); - } - - protected override bool OnHover(HoverEvent e) - { - updateButtonVisibility(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateButtonVisibility(); - base.OnHoverLost(e); - } - - private void collectionChanged() - { - Debug.Assert(collectionBeatmaps != null); - - beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo.MD5Hash); - - addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; - addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; - addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; - - updateButtonVisibility(); - } - - protected override void OnSelectChange() - { - base.OnSelectChange(); - updateButtonVisibility(); - } - - private void updateButtonVisibility() - { - if (collectionBeatmaps == null) - addOrRemoveButton.Alpha = 0; - else - addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; - } - - private void addOrRemove() - { - Debug.Assert(collectionBeatmaps != null); - - if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) - collectionBeatmaps.Add(beatmap.Value.BeatmapInfo.MD5Hash); - } - - protected override Drawable CreateContent() => content = (Content)base.CreateContent(); - } - } -} diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index 031f05c0b4..2ac5784f09 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.cs @@ -1,11 +1,8 @@ // 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 JetBrains.Annotations; -using osu.Framework.Bindables; +using osu.Game.Database; namespace osu.Game.Collections { @@ -18,26 +15,29 @@ namespace osu.Game.Collections /// The collection to filter beatmaps from. /// May be null to not filter by collection (include all beatmaps). /// - [CanBeNull] - public readonly BeatmapCollection Collection; + public readonly Live? Collection; /// /// The name of the collection. /// - [NotNull] - public readonly Bindable CollectionName; + public string CollectionName { get; } /// /// Creates a new . /// /// The collection to filter beatmaps from. - public CollectionFilterMenuItem([CanBeNull] BeatmapCollection collection) + public CollectionFilterMenuItem(Live collection) + : this(collection.PerformRead(c => c.Name)) { Collection = collection; - CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable("All beatmaps"); } - public bool Equals(CollectionFilterMenuItem other) + protected CollectionFilterMenuItem(string name) + { + CollectionName = name; + } + + public bool Equals(CollectionFilterMenuItem? other) { if (other == null) return false; @@ -45,20 +45,20 @@ namespace osu.Game.Collections // collections may have the same name, so compare first on reference equality. // this relies on the assumption that only one instance of the BeatmapCollection exists game-wide, managed by CollectionManager. if (Collection != null) - return Collection == other.Collection; + return Collection.ID == other.Collection?.ID; // fallback to name-based comparison. // this is required for special dropdown items which don't have a collection (all beatmaps / manage collections items below). - return CollectionName.Value == other.CollectionName.Value; + return CollectionName == other.CollectionName; } - public override int GetHashCode() => CollectionName.Value.GetHashCode(); + public override int GetHashCode() => CollectionName.GetHashCode(); } public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem { public AllBeatmapsCollectionFilterMenuItem() - : base(null) + : base("All beatmaps") { } } @@ -66,9 +66,8 @@ namespace osu.Game.Collections public class ManageCollectionsFilterMenuItem : CollectionFilterMenuItem { public ManageCollectionsFilterMenuItem() - : base(null) + : base("Manage collections...") { - CollectionName.Value = "Manage collections..."; } } } diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs deleted file mode 100644 index 796b3c426c..0000000000 --- a/osu.Game/Collections/CollectionManager.cs +++ /dev/null @@ -1,349 +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 System.Collections.Generic; -using System.Collections.Specialized; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Game.Database; -using osu.Game.IO; -using osu.Game.IO.Legacy; -using osu.Game.Overlays.Notifications; - -namespace osu.Game.Collections -{ - /// - /// Handles user-defined collections of beatmaps. - /// - /// - /// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the - /// database backing the game. Going forward writing should be done in a similar way to other model stores. - /// - public class CollectionManager : Component, IPostNotifications - { - /// - /// Database version in stable-compatible YYYYMMDD format. - /// - private const int database_version = 30000000; - - private const string database_name = "collection.db"; - private const string database_backup_name = "collection.db.bak"; - - public readonly BindableList Collections = new BindableList(); - - private readonly Storage storage; - - public CollectionManager(Storage storage) - { - this.storage = storage; - } - - [Resolved(canBeNull: true)] - private DatabaseContextFactory efContextFactory { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load() - { - efContextFactory?.WaitForMigrationCompletion(); - - Collections.CollectionChanged += collectionsChanged; - - if (storage.Exists(database_backup_name)) - { - // If a backup file exists, it means the previous write operation didn't run to completion. - // Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed. - // - // The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case. - if (storage.Exists(database_name)) - storage.Delete(database_name); - File.Copy(storage.GetFullPath(database_backup_name), storage.GetFullPath(database_name)); - } - - if (storage.Exists(database_name)) - { - List beatmapCollections; - - using (var stream = storage.GetStream(database_name)) - beatmapCollections = readCollections(stream); - - // intentionally fire-and-forget async. - importCollections(beatmapCollections); - } - } - - private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var c in e.NewItems.Cast()) - c.Changed += backgroundSave; - break; - - case NotifyCollectionChangedAction.Remove: - foreach (var c in e.OldItems.Cast()) - c.Changed -= backgroundSave; - break; - - case NotifyCollectionChangedAction.Replace: - foreach (var c in e.OldItems.Cast()) - c.Changed -= backgroundSave; - - foreach (var c in e.NewItems.Cast()) - c.Changed += backgroundSave; - break; - } - - backgroundSave(); - }); - - public Action PostNotification { protected get; set; } - - public Task GetAvailableCount(StableStorage stableStorage) - { - if (!stableStorage.Exists(database_name)) - return Task.FromResult(0); - - return Task.Run(() => - { - using (var stream = stableStorage.GetStream(database_name)) - return readCollections(stream).Count; - }); - } - - /// - /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. - /// - public Task ImportFromStableAsync(StableStorage stableStorage) - { - if (!stableStorage.Exists(database_name)) - { - // This handles situations like when the user does not have a collections.db file - Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); - return Task.CompletedTask; - } - - return Task.Run(async () => - { - using (var stream = stableStorage.GetStream(database_name)) - await Import(stream).ConfigureAwait(false); - }); - } - - public async Task Import(Stream stream) - { - var notification = new ProgressNotification - { - State = ProgressNotificationState.Active, - Text = "Collections import is initialising..." - }; - - PostNotification?.Invoke(notification); - - var collections = readCollections(stream, notification); - await importCollections(collections).ConfigureAwait(false); - - notification.CompletionText = $"Imported {collections.Count} collections"; - notification.State = ProgressNotificationState.Completed; - } - - private Task importCollections(List newCollections) - { - var tcs = new TaskCompletionSource(); - - Schedule(() => - { - try - { - foreach (var newCol in newCollections) - { - var existing = Collections.FirstOrDefault(c => c.Name.Value == newCol.Name.Value); - if (existing == null) - Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); - - foreach (string newBeatmap in newCol.BeatmapHashes) - { - if (!existing.BeatmapHashes.Contains(newBeatmap)) - existing.BeatmapHashes.Add(newBeatmap); - } - } - - tcs.SetResult(true); - } - catch (Exception e) - { - Logger.Error(e, "Failed to import collection."); - tcs.SetException(e); - } - }); - - return tcs.Task; - } - - private List readCollections(Stream stream, ProgressNotification notification = null) - { - if (notification != null) - { - notification.Text = "Reading collections..."; - notification.Progress = 0; - } - - var result = new List(); - - try - { - using (var sr = new SerializationReader(stream)) - { - sr.ReadInt32(); // Version - - int collectionCount = sr.ReadInt32(); - result.Capacity = collectionCount; - - for (int i = 0; i < collectionCount; i++) - { - if (notification?.CancellationToken.IsCancellationRequested == true) - return result; - - var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } }; - int mapCount = sr.ReadInt32(); - - for (int j = 0; j < mapCount; j++) - { - if (notification?.CancellationToken.IsCancellationRequested == true) - return result; - - string checksum = sr.ReadString(); - - collection.BeatmapHashes.Add(checksum); - } - - if (notification != null) - { - notification.Text = $"Imported {i + 1} of {collectionCount} collections"; - notification.Progress = (float)(i + 1) / collectionCount; - } - - result.Add(collection); - } - } - } - catch (Exception e) - { - Logger.Error(e, "Failed to read collection database."); - } - - return result; - } - - public void DeleteAll() - { - Collections.Clear(); - PostNotification?.Invoke(new ProgressCompletionNotification { Text = "Deleted all collections!" }); - } - - private readonly object saveLock = new object(); - private int lastSave; - private int saveFailures; - - /// - /// Perform a save with debounce. - /// - private void backgroundSave() - { - int current = Interlocked.Increment(ref lastSave); - Task.Delay(100).ContinueWith(_ => - { - if (current != lastSave) - return; - - if (!save()) - backgroundSave(); - }); - } - - private bool save() - { - lock (saveLock) - { - Interlocked.Increment(ref lastSave); - - // This is NOT thread-safe!! - try - { - string tempPath = Path.GetTempFileName(); - - using (var ms = new MemoryStream()) - { - using (var sw = new SerializationWriter(ms, true)) - { - sw.Write(database_version); - - var collectionsCopy = Collections.ToArray(); - sw.Write(collectionsCopy.Length); - - foreach (var c in collectionsCopy) - { - sw.Write(c.Name.Value); - - string[] beatmapsCopy = c.BeatmapHashes.ToArray(); - - sw.Write(beatmapsCopy.Length); - - foreach (string b in beatmapsCopy) - sw.Write(b); - } - } - - using (var fs = File.OpenWrite(tempPath)) - ms.WriteTo(fs); - - string databasePath = storage.GetFullPath(database_name); - string databaseBackupPath = storage.GetFullPath(database_backup_name); - - // Back up the existing database, clearing any existing backup. - if (File.Exists(databaseBackupPath)) - File.Delete(databaseBackupPath); - if (File.Exists(databasePath)) - File.Move(databasePath, databaseBackupPath); - - // Move the new database in-place of the existing one. - File.Move(tempPath, databasePath); - - // If everything succeeded up to this point, remove the backup file. - if (File.Exists(databaseBackupPath)) - File.Delete(databaseBackupPath); - } - - if (saveFailures < 10) - saveFailures = 0; - return true; - } - catch (Exception e) - { - // Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing). - // Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred. - if (++saveFailures == 10) - Logger.Error(e, "Failed to save collection database!"); - } - - return false; - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - save(); - } - } -} diff --git a/osu.Game/Collections/CollectionToggleMenuItem.cs b/osu.Game/Collections/CollectionToggleMenuItem.cs new file mode 100644 index 0000000000..5ad06a72c0 --- /dev/null +++ b/osu.Game/Collections/CollectionToggleMenuItem.cs @@ -0,0 +1,27 @@ +// 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.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Collections +{ + public class CollectionToggleMenuItem : ToggleMenuItem + { + public CollectionToggleMenuItem(Live collection, IBeatmapInfo beatmap) + : base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state => + { + collection.PerformWrite(c => + { + if (state) + c.BeatmapMD5Hashes.Add(beatmap.MD5Hash); + else + c.BeatmapMD5Hashes.Remove(beatmap.MD5Hash); + }); + }) + { + State.Value = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.MD5Hash)); + } + } +} diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs index 1da2870913..bf187265c1 100644 --- a/osu.Game/Collections/DeleteCollectionDialog.cs +++ b/osu.Game/Collections/DeleteCollectionDialog.cs @@ -1,36 +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 Humanizer; -using osu.Framework.Graphics.Sprites; +using osu.Game.Database; using osu.Game.Overlays.Dialog; namespace osu.Game.Collections { - public class DeleteCollectionDialog : PopupDialog + public class DeleteCollectionDialog : DeleteConfirmationDialog { - public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction) + public DeleteCollectionDialog(Live collection, Action deleteAction) { - HeaderText = "Confirm deletion of"; - BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.BeatmapHashes.Count)})"; - - Icon = FontAwesome.Regular.TrashAlt; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Yes. Go for it.", - Action = deleteAction - }, - new PopupDialogCancelButton - { - Text = @"No! Abort mission!", - }, - }; + BodyText = collection.PerformRead(c => $"{c.Name} ({"beatmap".ToQuantity(c.BeatmapMD5Hashes.Count)})"); + DeleteAction = deleteAction; } } } diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 4fe5733c2f..0f4362fff3 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -1,39 +1,66 @@ // 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.Diagnostics; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osuTK; +using Realms; namespace osu.Game.Collections { /// /// Visualises a list of s. /// - public class DrawableCollectionList : OsuRearrangeableListContainer + public class DrawableCollectionList : OsuRearrangeableListContainer> { - private Scroll scroll; - protected override ScrollContainer CreateScrollContainer() => scroll = new Scroll(); - protected override FillFlowContainer> CreateListFillFlowContainer() => new Flow + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private Scroll scroll = null!; + + private IDisposable? realmSubscription; + + protected override FillFlowContainer>> CreateListFillFlowContainer() => new Flow { DragActive = { BindTarget = DragActive } }; - protected override OsuRearrangeableListItem CreateOsuDrawable(BeatmapCollection item) + protected override void LoadComplete() { - if (item == scroll.PlaceholderItem.Model) + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); + } + + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes, Exception error) + { + Items.Clear(); + Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); + } + + protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) + { + if (item.ID == scroll.PlaceholderItem.Model.ID) return scroll.ReplacePlaceholder(); return new DrawableCollectionListItem(item, true); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + /// /// The scroll container for this . /// Contains the main flow of and attaches a placeholder item to the end of the list. @@ -46,7 +73,7 @@ namespace osu.Game.Collections /// /// The currently-displayed placeholder item. /// - public DrawableCollectionListItem PlaceholderItem { get; private set; } + public DrawableCollectionListItem PlaceholderItem { get; private set; } = null!; protected override Container Content => content; private readonly Container content; @@ -76,6 +103,7 @@ namespace osu.Game.Collections }); ReplacePlaceholder(); + Debug.Assert(PlaceholderItem != null); } protected override void Update() @@ -95,7 +123,7 @@ namespace osu.Game.Collections var previous = PlaceholderItem; placeholderContainer.Clear(false); - placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false)); + placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection().ToLiveUnmanaged(), false)); return previous; } @@ -104,7 +132,7 @@ namespace osu.Game.Collections /// /// The flow of . Disables layout easing unless a drag is in progress. /// - private class Flow : FillFlowContainer> + private class Flow : FillFlowContainer>> { public readonly IBindable DragActive = new Bindable(); diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 4596fc0e52..d1e40f6262 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -1,17 +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 osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -24,79 +23,62 @@ namespace osu.Game.Collections /// /// Visualises a inside a . /// - public class DrawableCollectionListItem : OsuRearrangeableListItem + public class DrawableCollectionListItem : OsuRearrangeableListItem> { private const float item_height = 35; private const float button_width = item_height * 0.75f; - /// - /// Whether the currently exists inside the . - /// - public IBindable IsCreated => isCreated; - - private readonly Bindable isCreated = new Bindable(); - /// /// Creates a new . /// /// The . - /// Whether currently exists inside the . - public DrawableCollectionListItem(BeatmapCollection item, bool isCreated) + /// Whether currently exists inside realm. + public DrawableCollectionListItem(Live item, bool isCreated) : base(item) { - this.isCreated.Value = isCreated; - - ShowDragHandle.BindTo(this.isCreated); + ShowDragHandle.Value = item.IsManaged; } - protected override Drawable CreateContent() => new ItemContent(Model) - { - IsCreated = { BindTarget = isCreated } - }; + protected override Drawable CreateContent() => new ItemContent(Model); /// /// The main content of the . /// private class ItemContent : CircularContainer { - public readonly Bindable IsCreated = new Bindable(); + private readonly Live collection; - private readonly IBindable collectionName; - private readonly BeatmapCollection collection; + private ItemTextBox textBox = null!; - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + [Resolved] + private RealmAccess realm { get; set; } = null!; - private Container textBoxPaddingContainer; - private ItemTextBox textBox; - - public ItemContent(BeatmapCollection collection) + public ItemContent(Live collection) { this.collection = collection; RelativeSizeAxes = Axes.X; Height = item_height; Masking = true; - - collectionName = collection.Name.GetBoundCopy(); } [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + Children = new[] { - new DeleteButton(collection) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - IsCreated = { BindTarget = IsCreated }, - IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) - }, - textBoxPaddingContainer = new Container + collection.IsManaged + ? new DeleteButton(collection) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) + } + : Empty(), + new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = button_width }, + Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { textBox = new ItemTextBox @@ -104,7 +86,7 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.Both, Size = Vector2.One, CornerRadius = item_height / 2, - PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection" + PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } }, @@ -116,28 +98,18 @@ namespace osu.Game.Collections base.LoadComplete(); // Bind late, as the collection name may change externally while still loading. - textBox.Current = collection.Name; - - collectionName.BindValueChanged(_ => createNewCollection(), true); - IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true); + textBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty); + textBox.OnCommit += onCommit; } - private void createNewCollection() + private void onCommit(TextBox sender, bool newText) { - if (IsCreated.Value) - return; + if (collection.IsManaged) + collection.PerformWrite(c => c.Name = textBox.Current.Value); + else if (!string.IsNullOrEmpty(textBox.Current.Value)) + realm.Write(r => r.Add(new BeatmapCollection(textBox.Current.Value))); - if (string.IsNullOrEmpty(collectionName.Value)) - return; - - // Add the new collection and disable our placeholder. If all text is removed, the placeholder should not show back again. - collectionManager?.Collections.Add(collection); - textBox.PlaceholderText = string.Empty; - - // When this item changes from placeholder to non-placeholder (via changing containers), its textbox will lose focus, so it needs to be re-focused. - Schedule(() => GetContainingInputManager().ChangeFocus(textBox)); - - IsCreated.Value = true; + textBox.Text = string.Empty; } } @@ -155,22 +127,17 @@ namespace osu.Game.Collections public class DeleteButton : CompositeDrawable { - public readonly IBindable IsCreated = new Bindable(); + public Func IsTextBoxHovered = null!; - public Func IsTextBoxHovered; + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } - [Resolved(CanBeNull = true)] - private IDialogOverlay dialogOverlay { get; set; } + private readonly Live collection; - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + private Drawable fadeContainer = null!; + private Drawable background = null!; - private readonly BeatmapCollection collection; - - private Drawable fadeContainer; - private Drawable background; - - public DeleteButton(BeatmapCollection collection) + public DeleteButton(Live collection) { this.collection = collection; RelativeSizeAxes = Axes.Y; @@ -204,12 +171,6 @@ namespace osu.Game.Collections }; } - protected override void LoadComplete() - { - base.LoadComplete(); - IsCreated.BindValueChanged(created => Alpha = created.NewValue ? 1 : 0, true); - } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); protected override bool OnHover(HoverEvent e) @@ -227,7 +188,7 @@ namespace osu.Game.Collections { background.FlashColour(Color4.White, 150); - if (collection.BeatmapHashes.Count == 0) + if (collection.PerformRead(c => c.BeatmapMD5Hashes.Count) == 0) deleteCollection(); else dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); @@ -235,7 +196,7 @@ namespace osu.Game.Collections return true; } - private void deleteCollection() => collectionManager?.Collections.Remove(collection); + private void deleteCollection() => collection.PerformWrite(c => c.Realm.Remove(c)); } } } diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index a9d699bc9f..13737dbd78 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -1,11 +1,8 @@ // 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.Allocation; using osu.Framework.Audio; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -24,10 +21,7 @@ namespace osu.Game.Collections private const double enter_duration = 500; private const double exit_duration = 200; - private AudioFilter lowPassFilter; - - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + private AudioFilter lowPassFilter = null!; public ManageCollectionsDialog() { @@ -107,7 +101,6 @@ namespace osu.Game.Collections new DrawableCollectionList { RelativeSizeAxes = Axes.Both, - Items = { BindTarget = collectionManager?.Collections ?? new BindableList() } } } } diff --git a/osu.Game/Configuration/BackgroundSource.cs b/osu.Game/Configuration/BackgroundSource.cs index b4a1fd78c3..f08b29cffe 100644 --- a/osu.Game/Configuration/BackgroundSource.cs +++ b/osu.Game/Configuration/BackgroundSource.cs @@ -3,16 +3,20 @@ #nullable disable -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum BackgroundSource { + [LocalisableDescription(typeof(SkinSettingsStrings), nameof(SkinSettingsStrings.SkinSectionHeader))] Skin, + + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.BeatmapHeader))] Beatmap, - [Description("Beatmap (with storyboard / video)")] + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.BeatmapWithStoryboard))] BeatmapWithStoryboard, } } diff --git a/osu.Game/Configuration/DiscordRichPresenceMode.cs b/osu.Game/Configuration/DiscordRichPresenceMode.cs index 935619920a..150d23447e 100644 --- a/osu.Game/Configuration/DiscordRichPresenceMode.cs +++ b/osu.Game/Configuration/DiscordRichPresenceMode.cs @@ -3,17 +3,20 @@ #nullable disable -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum DiscordRichPresenceMode { + [LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.DiscordPresenceOff))] Off, - [Description("Hide identifiable information")] + [LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.HideIdentifiableInformation))] Limited, + [LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.DiscordPresenceFull))] Full } } diff --git a/osu.Game/Configuration/HUDVisibilityMode.cs b/osu.Game/Configuration/HUDVisibilityMode.cs index ec54bfcb71..9c69f33220 100644 --- a/osu.Game/Configuration/HUDVisibilityMode.cs +++ b/osu.Game/Configuration/HUDVisibilityMode.cs @@ -3,17 +3,20 @@ #nullable disable -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum HUDVisibilityMode { + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.NeverShowHUD))] Never, - [Description("Hide during gameplay")] + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.HideDuringGameplay))] HideDuringGameplay, + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.AlwaysShowHUD))] Always } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index a523507205..1378e1691a 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -4,10 +4,8 @@ #nullable disable using System; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; -using System.Linq; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; @@ -31,11 +29,17 @@ namespace osu.Game.Configuration [ExcludeFromDynamicCompile] public class OsuConfigManager : IniConfigManager { + public OsuConfigManager(Storage storage) + : base(storage) + { + Migrate(); + } + protected override void InitialiseDefaults() { // UI/selection defaults SetDefault(OsuSetting.Ruleset, string.Empty); - SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString()); + SetDefault(OsuSetting.Skin, SkinInfo.ARGON_SKIN.ToString()); SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); @@ -91,6 +95,7 @@ namespace osu.Game.Configuration // Input SetDefault(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); SetDefault(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); + SetDefault(OsuSetting.GameplayCursorDuringTouch, false); SetDefault(OsuSetting.AutoCursorSize, false); SetDefault(OsuSetting.MouseDisableButtons, false); @@ -115,7 +120,7 @@ namespace osu.Game.Configuration // Gameplay SetDefault(OsuSetting.PositionalHitsounds, true); // replaced by level setting below, can be removed 20220703. SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1); - SetDefault(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); + SetDefault(OsuSetting.DimLevel, 0.7, 0, 1, 0.01); SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); SetDefault(OsuSetting.LightenDuringBreaks, true); @@ -126,6 +131,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); SetDefault(OsuSetting.KeyOverlay, false); + SetDefault(OsuSetting.GameplayLeaderboard, true); SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); SetDefault(OsuSetting.FloatingComments, false); @@ -171,12 +177,9 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.LastProcessedMetadataId, -1); } - public IDictionary GetLoggableState() => - new Dictionary(ConfigStore.Where(kvp => !keyContainsPrivateInformation(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString())); - - private static bool keyContainsPrivateInformation(OsuSetting argKey) + protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) { - switch (argKey) + switch (lookup) { case OsuSetting.Token: return true; @@ -185,12 +188,6 @@ namespace osu.Game.Configuration return false; } - public OsuConfigManager(Storage storage) - : base(storage) - { - Migrate(); - } - public void Migrate() { // arrives as 2020.123.0 @@ -224,6 +221,12 @@ namespace osu.Game.Configuration return new TrackedSettings { + new TrackedSetting(OsuSetting.ShowFpsDisplay, state => new SettingDescription( + rawValue: state, + name: GlobalActionKeyBindingStrings.ToggleFPSCounter, + value: state ? CommonStrings.Enabled.ToLower() : CommonStrings.Disabled.ToLower(), + shortcut: LookupKeyBindings(GlobalAction.ToggleFPSDisplay)) + ), new TrackedSetting(OsuSetting.MouseDisableButtons, disabledState => new SettingDescription( rawValue: !disabledState, name: GlobalActionKeyBindingStrings.ToggleGameplayMouseButtons, @@ -286,11 +289,13 @@ namespace osu.Game.Configuration MenuCursorSize, GameplayCursorSize, AutoCursorSize, + GameplayCursorDuringTouch, DimLevel, BlurLevel, LightenDuringBreaks, ShowStoryboard, KeyOverlay, + GameplayLeaderboard, PositionalHitsounds, PositionalHitsoundsLevel, AlwaysPlayFirstComboBreak, diff --git a/osu.Game/Configuration/RandomSelectAlgorithm.cs b/osu.Game/Configuration/RandomSelectAlgorithm.cs index 9a02aaee60..052c6b4c55 100644 --- a/osu.Game/Configuration/RandomSelectAlgorithm.cs +++ b/osu.Game/Configuration/RandomSelectAlgorithm.cs @@ -3,16 +3,17 @@ #nullable disable -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum RandomSelectAlgorithm { - [Description("Never repeat")] + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.NeverRepeat))] RandomPermutation, - [Description("True Random")] + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.TrueRandom))] Random } } diff --git a/osu.Game/Configuration/ScalingMode.cs b/osu.Game/Configuration/ScalingMode.cs index 25c3692a1a..e0ad59746b 100644 --- a/osu.Game/Configuration/ScalingMode.cs +++ b/osu.Game/Configuration/ScalingMode.cs @@ -3,17 +3,23 @@ #nullable disable -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum ScalingMode { + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScalingOff))] Off, + + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleEverything))] Everything, - [Description("Excluding overlays")] + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleEverythingExcludingOverlays))] ExcludeOverlays, + + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleGameplay))] Gameplay, } } diff --git a/osu.Game/Configuration/ScreenshotFormat.cs b/osu.Game/Configuration/ScreenshotFormat.cs index 57c08538b8..13d0b64fd2 100644 --- a/osu.Game/Configuration/ScreenshotFormat.cs +++ b/osu.Game/Configuration/ScreenshotFormat.cs @@ -3,16 +3,17 @@ #nullable disable -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum ScreenshotFormat { - [Description("JPG (web-friendly)")] + [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.Jpg))] Jpg = 1, - [Description("PNG (lossless)")] + [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.Png))] Png = 2 } } diff --git a/osu.Game/Configuration/SeasonalBackgroundMode.cs b/osu.Game/Configuration/SeasonalBackgroundMode.cs index 909798fe97..3e6d9e42aa 100644 --- a/osu.Game/Configuration/SeasonalBackgroundMode.cs +++ b/osu.Game/Configuration/SeasonalBackgroundMode.cs @@ -3,6 +3,9 @@ #nullable disable +using osu.Framework.Localisation; +using osu.Game.Localisation; + namespace osu.Game.Configuration { public enum SeasonalBackgroundMode @@ -10,16 +13,19 @@ namespace osu.Game.Configuration /// /// Seasonal backgrounds are shown regardless of season, if at all available. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.AlwaysSeasonalBackground))] Always, /// /// Seasonal backgrounds are shown only during their corresponding season. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.SometimesSeasonalBackground))] Sometimes, /// /// Seasonal backgrounds are never shown. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.NeverSeasonalBackground))] Never } } 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/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs deleted file mode 100644 index af91fb4971..0000000000 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ /dev/null @@ -1,218 +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 System.IO; -using System.Linq; -using System.Threading; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Framework.Statistics; - -namespace osu.Game.Database -{ - public class DatabaseContextFactory : IDatabaseContextFactory - { - private readonly Storage storage; - - public const string DATABASE_NAME = @"client.db"; - - private ThreadLocal threadContexts; - - private readonly object writeLock = new object(); - - private bool currentWriteDidWrite; - private bool currentWriteDidError; - - private int currentWriteUsages; - - private IDbContextTransaction currentWriteTransaction; - - public DatabaseContextFactory(Storage storage) - { - this.storage = storage; - recycleThreadContexts(); - } - - private static readonly GlobalStatistic reads = GlobalStatistics.Get("Database", "Get (Read)"); - private static readonly GlobalStatistic writes = GlobalStatistics.Get("Database", "Get (Write)"); - private static readonly GlobalStatistic commits = GlobalStatistics.Get("Database", "Commits"); - private static readonly GlobalStatistic rollbacks = GlobalStatistics.Get("Database", "Rollbacks"); - - /// - /// Get a context for the current thread for read-only usage. - /// If a is in progress, the existing write-safe context will be returned. - /// - public OsuDbContext Get() - { - reads.Value++; - return threadContexts.Value; - } - - /// - /// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context). - /// This method may block if a write is already active on a different thread. - /// - /// Whether to start a transaction for this write. - /// A usage containing a usable context. - public DatabaseWriteUsage GetForWrite(bool withTransaction = true) - { - writes.Value++; - Monitor.Enter(writeLock); - OsuDbContext context; - - try - { - if (currentWriteTransaction == null && withTransaction) - { - // this mitigates the fact that changes on tracked entities will not be rolled back with the transaction by ensuring write operations are always executed in isolated contexts. - // if this results in sub-optimal efficiency, we may need to look into removing Database-level transactions in favour of running SaveChanges where we currently commit the transaction. - if (threadContexts.IsValueCreated) - recycleThreadContexts(); - - context = threadContexts.Value; - currentWriteTransaction = context.Database.BeginTransaction(); - } - else - { - // we want to try-catch the retrieval of the context because it could throw an error (in CreateContext). - context = threadContexts.Value; - } - } - catch - { - // retrieval of a context could trigger a fatal error. - Monitor.Exit(writeLock); - throw; - } - - Interlocked.Increment(ref currentWriteUsages); - - return new DatabaseWriteUsage(context, usageCompleted) { IsTransactionLeader = currentWriteTransaction != null && currentWriteUsages == 1 }; - } - - private void usageCompleted(DatabaseWriteUsage usage) - { - int usages = Interlocked.Decrement(ref currentWriteUsages); - - try - { - currentWriteDidWrite |= usage.PerformedWrite; - currentWriteDidError |= usage.Errors.Any(); - - if (usages == 0) - { - if (currentWriteDidError) - { - rollbacks.Value++; - currentWriteTransaction?.Rollback(); - } - else - { - commits.Value++; - currentWriteTransaction?.Commit(); - } - - if (currentWriteDidWrite || currentWriteDidError) - { - // explicitly dispose to ensure any outstanding flushes happen as soon as possible (and underlying resources are purged). - usage.Context.Dispose(); - - // once all writes are complete, we want to refresh thread-specific contexts to make sure they don't have stale local caches. - recycleThreadContexts(); - } - - currentWriteTransaction = null; - currentWriteDidWrite = false; - currentWriteDidError = false; - } - } - finally - { - Monitor.Exit(writeLock); - } - } - - private void recycleThreadContexts() - { - // Contexts for other threads are not disposed as they may be in use elsewhere. Instead, fresh contexts are exposed - // for other threads to use, and we rely on the finalizer inside OsuDbContext to handle their previous contexts - threadContexts?.Value.Dispose(); - threadContexts = new ThreadLocal(CreateContext, true); - } - - protected virtual OsuDbContext CreateContext() => new OsuDbContext(CreateDatabaseConnectionString(DATABASE_NAME, storage)) - { - Database = { AutoTransactionsEnabled = false } - }; - - public void CreateBackup(string backupFilename) - { - Logger.Log($"Creating full EF database backup at {backupFilename}", LoggingTarget.Database); - - using (var source = storage.GetStream(DATABASE_NAME, mode: FileMode.Open)) - using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) - source.CopyTo(destination); - } - - public void ResetDatabase() - { - lock (writeLock) - { - recycleThreadContexts(); - - try - { - int attempts = 10; - - // Retry logic taken from MigratableStorage.AttemptOperation. - while (true) - { - try - { - storage.Delete(DATABASE_NAME); - return; - } - catch (Exception) - { - if (attempts-- == 0) - throw; - } - - Thread.Sleep(250); - } - } - catch - { - // for now we are not sure why file handles are kept open by EF, but this is generally only used in testing - } - } - } - - public void FlushConnections() - { - if (threadContexts != null) - { - foreach (var context in threadContexts.Values) - context.Dispose(); - } - - recycleThreadContexts(); - } - - public static string CreateDatabaseConnectionString(string filename, Storage storage) => string.Concat("Data Source=", storage.GetFullPath($@"{filename}", true)); - - private readonly ManualResetEventSlim migrationComplete = new ManualResetEventSlim(); - - public void SetMigrationCompletion() => migrationComplete.Set(); - - public void WaitForMigrationCompletion() - { - if (!migrationComplete.Wait(300000)) - throw new TimeoutException("Migration took too long (likely stuck)."); - } - } -} diff --git a/osu.Game/Database/DatabaseWriteUsage.cs b/osu.Game/Database/DatabaseWriteUsage.cs deleted file mode 100644 index b5f25eae20..0000000000 --- a/osu.Game/Database/DatabaseWriteUsage.cs +++ /dev/null @@ -1,60 +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 System.Collections.Generic; - -namespace osu.Game.Database -{ - public class DatabaseWriteUsage : IDisposable - { - public readonly OsuDbContext Context; - private readonly Action usageCompleted; - - public DatabaseWriteUsage(OsuDbContext context, Action onCompleted) - { - Context = context; - usageCompleted = onCompleted; - } - - public bool PerformedWrite { get; private set; } - - private bool isDisposed; - public List Errors = new List(); - - /// - /// Whether this write usage will commit a transaction on completion. - /// If false, there is a parent usage responsible for transaction commit. - /// - public bool IsTransactionLeader; - - protected void Dispose(bool disposing) - { - if (isDisposed) return; - - isDisposed = true; - - try - { - PerformedWrite |= Context.SaveChanges() > 0; - } - catch (Exception e) - { - Errors.Add(e); - throw; - } - finally - { - usageCompleted?.Invoke(this); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs deleted file mode 100644 index 3b5424b3fb..0000000000 --- a/osu.Game/Database/EFToRealmMigrator.cs +++ /dev/null @@ -1,596 +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 System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using osu.Framework; -using osu.Framework.Allocation; -using osu.Framework.Development; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Models; -using osu.Game.Overlays; -using osu.Game.Overlays.Notifications; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Skinning; -using osuTK; -using Realms; -using SharpCompress.Archives; -using SharpCompress.Archives.Zip; -using SharpCompress.Common; -using SharpCompress.Writers.Zip; - -namespace osu.Game.Database -{ - internal class EFToRealmMigrator : CompositeDrawable - { - public Task MigrationCompleted => migrationCompleted.Task; - - private readonly TaskCompletionSource migrationCompleted = new TaskCompletionSource(); - - [Resolved] - private DatabaseContextFactory efContextFactory { get; set; } = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - [Resolved] - private INotificationOverlay notificationOverlay { get; set; } = null!; - - [Resolved] - private OsuGame game { get; set; } = null!; - - [Resolved] - private Storage storage { get; set; } = null!; - - private readonly OsuTextFlowContainer currentOperationText; - - public EFToRealmMigrator() - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Spacing = new Vector2(10), - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Database migration in progress", - Font = OsuFont.Default.With(size: 40) - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "This could take a few minutes depending on the speed of your disk(s).", - Font = OsuFont.Default.With(size: 30) - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Please keep the window open until this completes!", - Font = OsuFont.Default.With(size: 30) - }, - new LoadingSpinner(true) - { - State = { Value = Visibility.Visible } - }, - currentOperationText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 30)) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre, - }, - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - beginMigration(); - } - - private void beginMigration() - { - const string backup_folder = "backups"; - - string backupSuffix = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; - - // required for initial backup. - var realmBlockOperations = realm.BlockAllOperations("EF migration"); - - Task.Factory.StartNew(() => - { - try - { - realm.CreateBackup(Path.Combine(backup_folder, $"client.{backupSuffix}.realm")); - } - finally - { - // Once the backup is created, we need to stop blocking operations so the migration can complete. - realmBlockOperations.Dispose(); - // Clean up here so we don't accidentally dispose twice. - realmBlockOperations = null; - } - - efContextFactory.CreateBackup(Path.Combine(backup_folder, $"client.{backupSuffix}.db")); - - using (var ef = efContextFactory.Get()) - { - realm.Write(r => - { - // Before beginning, ensure realm is in an empty state. - // Migrations which are half-completed could lead to issues if the user tries a second time. - // Note that we only do this for beatmaps and scores since the other migrations are yonks old. - r.RemoveAll(); - r.RemoveAll(); - r.RemoveAll(); - r.RemoveAll(); - }); - - ef.Migrate(); - - migrateSettings(ef); - migrateSkins(ef); - migrateBeatmaps(ef); - migrateScores(ef); - } - }, TaskCreationOptions.LongRunning).ContinueWith(t => - { - if (t.Exception == null) - { - log("Migration successful!"); - - if (DebugUtils.IsDebugBuild) - { - Logger.Log( - "Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.", - level: LogLevel.Important); - } - } - else - { - log("Migration failed!"); - Logger.Log(t.Exception.ToString(), LoggingTarget.Database); - - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && t.Exception.Flatten().InnerException is TypeInitializationException) - { - // Not guaranteed to be the only cause of exception, but let's roll with it for now. - log("Please download and run the intel version of osu! once\nto allow data migration to complete!"); - efContextFactory.SetMigrationCompletion(); - return; - } - - notificationOverlay.Post(new SimpleErrorNotification - { - Text = - "IMPORTANT: During data migration, some of your data could not be successfully migrated. The previous version has been backed up.\n\nFor further assistance, please open a discussion on github and attach your backup files (click to get started).", - Activated = () => - { - game.OpenUrlExternally( - $@"https://github.com/ppy/osu/discussions/new?title=Realm%20migration%20issue ({t.Exception.Message})&body=Please%20drag%20the%20""attach_me.zip""%20file%20here!&category=q-a", - true); - - const string attachment_filename = "attach_me.zip"; - - var backupStorage = storage.GetStorageForDirectory(backup_folder); - - backupStorage.Delete(attachment_filename); - - try - { - using (var zip = ZipArchive.Create()) - { - zip.AddAllFromDirectory(backupStorage.GetFullPath(string.Empty)); - zip.SaveTo(Path.Combine(backupStorage.GetFullPath(string.Empty), attachment_filename), new ZipWriterOptions(CompressionType.Deflate)); - } - } - catch { } - - backupStorage.PresentFileExternally(attachment_filename); - - return true; - } - }); - } - - // Regardless of success, since the game is going to continue with startup let's move the ef database out of the way. - // If we were to not do this, the migration would run another time the next time the user starts the game. - deletePreRealmData(); - - // If something went wrong and the disposal token wasn't invoked above, ensure it is here. - realmBlockOperations?.Dispose(); - - migrationCompleted.SetResult(true); - efContextFactory.SetMigrationCompletion(); - }); - } - - private void deletePreRealmData() - { - // Delete the database permanently. - // Will cause future startups to not attempt migration. - efContextFactory.ResetDatabase(); - } - - private void log(string message) - { - Logger.Log(message, LoggingTarget.Database); - Scheduler.AddOnce(m => currentOperationText.Text = m, message); - } - - private void migrateBeatmaps(OsuDbContext ef) - { - // can be removed 20220730. - var existingBeatmapSets = ef.EFBeatmapSetInfo - .Include(s => s.Beatmaps).ThenInclude(b => b.RulesetInfo) - .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) - .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) - .Include(s => s.Files).ThenInclude(f => f.FileInfo) - .Include(s => s.Metadata) - .AsSplitQuery(); - - log("Beginning beatmaps migration to realm"); - - // previous entries in EF are removed post migration. - if (!existingBeatmapSets.Any()) - { - log("No beatmaps found to migrate"); - return; - } - - int count = existingBeatmapSets.Count(); - - realm.Run(r => - { - log($"Found {count} beatmaps in EF"); - - var transaction = r.BeginWrite(); - int written = 0; - int missing = 0; - - try - { - foreach (var beatmapSet in existingBeatmapSets) - { - if (++written % 1000 == 0) - { - transaction.Commit(); - transaction = r.BeginWrite(); - log($"Migrated {written}/{count} beatmaps..."); - } - - var realmBeatmapSet = new BeatmapSetInfo - { - OnlineID = beatmapSet.OnlineID ?? -1, - DateAdded = beatmapSet.DateAdded, - Status = beatmapSet.Status, - DeletePending = beatmapSet.DeletePending, - Hash = beatmapSet.Hash, - Protected = beatmapSet.Protected, - }; - - migrateFiles(beatmapSet, r, realmBeatmapSet); - - foreach (var beatmap in beatmapSet.Beatmaps) - { - var ruleset = r.Find(beatmap.RulesetInfo.ShortName); - var metadata = getBestMetadata(beatmap.Metadata, beatmapSet.Metadata); - - if (ruleset == null) - { - log($"Skipping {++missing} beatmaps with missing ruleset"); - continue; - } - - var realmBeatmap = new BeatmapInfo(ruleset, new BeatmapDifficulty(beatmap.BaseDifficulty), metadata) - { - DifficultyName = beatmap.DifficultyName, - Status = beatmap.Status, - OnlineID = beatmap.OnlineID ?? -1, - Length = beatmap.Length, - BPM = beatmap.BPM, - Hash = beatmap.Hash, - StarRating = beatmap.StarRating, - MD5Hash = beatmap.MD5Hash, - Hidden = beatmap.Hidden, - AudioLeadIn = beatmap.AudioLeadIn, - StackLeniency = beatmap.StackLeniency, - SpecialStyle = beatmap.SpecialStyle, - LetterboxInBreaks = beatmap.LetterboxInBreaks, - WidescreenStoryboard = beatmap.WidescreenStoryboard, - EpilepsyWarning = beatmap.EpilepsyWarning, - SamplesMatchPlaybackRate = beatmap.SamplesMatchPlaybackRate, - DistanceSpacing = beatmap.DistanceSpacing, - BeatDivisor = beatmap.BeatDivisor, - GridSize = beatmap.GridSize, - TimelineZoom = beatmap.TimelineZoom, - Countdown = beatmap.Countdown, - CountdownOffset = beatmap.CountdownOffset, - Bookmarks = beatmap.Bookmarks, - BeatmapSet = realmBeatmapSet, - }; - - realmBeatmapSet.Beatmaps.Add(realmBeatmap); - } - - r.Add(realmBeatmapSet); - } - } - finally - { - transaction.Commit(); - } - - log($"Successfully migrated {count} beatmaps to realm"); - }); - } - - private BeatmapMetadata getBestMetadata(EFBeatmapMetadata? beatmapMetadata, EFBeatmapMetadata? beatmapSetMetadata) - { - var metadata = beatmapMetadata ?? beatmapSetMetadata ?? new EFBeatmapMetadata(); - - return new BeatmapMetadata - { - Title = metadata.Title, - TitleUnicode = metadata.TitleUnicode, - Artist = metadata.Artist, - ArtistUnicode = metadata.ArtistUnicode, - Author = - { - OnlineID = metadata.Author.Id, - Username = metadata.Author.Username, - }, - Source = metadata.Source, - Tags = metadata.Tags, - PreviewTime = metadata.PreviewTime, - AudioFile = metadata.AudioFile, - BackgroundFile = metadata.BackgroundFile, - }; - } - - private void migrateScores(OsuDbContext db) - { - // can be removed 20220730. - var existingScores = db.ScoreInfo - .Include(s => s.Ruleset) - .Include(s => s.BeatmapInfo) - .Include(s => s.Files) - .ThenInclude(f => f.FileInfo) - .AsSplitQuery(); - - log("Beginning scores migration to realm"); - - // previous entries in EF are removed post migration. - if (!existingScores.Any()) - { - log("No scores found to migrate"); - return; - } - - int count = existingScores.Count(); - - realm.Run(r => - { - log($"Found {count} scores in EF"); - - var transaction = r.BeginWrite(); - int written = 0; - int missing = 0; - - try - { - foreach (var score in existingScores) - { - if (++written % 1000 == 0) - { - transaction.Commit(); - transaction = r.BeginWrite(); - log($"Migrated {written}/{count} scores..."); - } - - var beatmap = r.All().FirstOrDefault(b => b.Hash == score.BeatmapInfo.Hash); - var ruleset = r.Find(score.Ruleset.ShortName); - - if (beatmap == null || ruleset == null) - { - log($"Skipping {++missing} scores with missing ruleset or beatmap"); - continue; - } - - var user = new RealmUser - { - OnlineID = score.User.OnlineID, - Username = score.User.Username - }; - - var realmScore = new ScoreInfo(beatmap, ruleset, user) - { - Hash = score.Hash, - DeletePending = score.DeletePending, - OnlineID = score.OnlineID ?? -1, - ModsJson = score.ModsJson, - StatisticsJson = score.StatisticsJson, - TotalScore = score.TotalScore, - MaxCombo = score.MaxCombo, - Accuracy = score.Accuracy, - HasReplay = ((IScoreInfo)score).HasReplay, - Date = score.Date, - PP = score.PP, - Rank = score.Rank, - HitEvents = score.HitEvents, - Passed = score.Passed, - Combo = score.Combo, - Position = score.Position, - Statistics = score.Statistics, - Mods = score.Mods, - APIMods = score.APIMods, - }; - - migrateFiles(score, r, realmScore); - - r.Add(realmScore); - } - } - finally - { - transaction.Commit(); - } - - log($"Successfully migrated {count} scores to realm"); - }); - } - - private void migrateSkins(OsuDbContext db) - { - // can be removed 20220530. - var existingSkins = db.SkinInfo - .Include(s => s.Files) - .ThenInclude(f => f.FileInfo) - .AsSplitQuery() - .ToList(); - - // previous entries in EF are removed post migration. - if (!existingSkins.Any()) - return; - - var userSkinChoice = config.GetBindable(OsuSetting.Skin); - int.TryParse(userSkinChoice.Value, out int userSkinInt); - - switch (userSkinInt) - { - case EFSkinInfo.DEFAULT_SKIN: - userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString(); - break; - - case EFSkinInfo.CLASSIC_SKIN: - userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString(); - break; - } - - realm.Run(r => - { - using (var transaction = r.BeginWrite()) - { - // only migrate data if the realm database is empty. - // note that this cannot be written as: `r.All().All(s => s.Protected)`, because realm does not support `.All()`. - if (!r.All().Any(s => !s.Protected)) - { - log($"Migrating {existingSkins.Count} skins"); - - foreach (var skin in existingSkins) - { - var realmSkin = new SkinInfo - { - Name = skin.Name, - Creator = skin.Creator, - Hash = skin.Hash, - Protected = false, - InstantiationInfo = skin.InstantiationInfo, - }; - - migrateFiles(skin, r, realmSkin); - - r.Add(realmSkin); - - if (skin.ID == userSkinInt) - userSkinChoice.Value = realmSkin.ID.ToString(); - } - } - - transaction.Commit(); - } - }); - } - - private static void migrateFiles(IHasFiles fileSource, Realm realm, IHasRealmFiles realmObject) where T : INamedFileInfo - { - foreach (var file in fileSource.Files) - { - var realmFile = realm.Find(file.FileInfo.Hash); - - if (realmFile == null) - realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash }); - - realmObject.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename)); - } - } - - private void migrateSettings(OsuDbContext db) - { - // migrate ruleset settings. can be removed 20220315. - var existingSettings = db.DatabasedSetting.ToList(); - - // previous entries in EF are removed post migration. - if (!existingSettings.Any()) - return; - - log("Beginning settings migration to realm"); - - realm.Run(r => - { - using (var transaction = r.BeginWrite()) - { - // only migrate data if the realm database is empty. - if (!r.All().Any()) - { - log($"Migrating {existingSettings.Count} settings"); - - foreach (var dkb in existingSettings) - { - if (dkb.RulesetID == null) - continue; - - string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value); - - if (string.IsNullOrEmpty(shortName)) - continue; - - r.Add(new RealmRulesetSetting - { - Key = dkb.Key, - Value = dkb.StringValue, - RulesetName = shortName, - Variant = dkb.Variant ?? 0, - }); - } - } - - transaction.Commit(); - } - }); - } - - private string? getRulesetShortNameFromLegacyID(long rulesetId) => - efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; - } -} diff --git a/osu.Game/Database/IDatabaseContextFactory.cs b/osu.Game/Database/IDatabaseContextFactory.cs deleted file mode 100644 index bddabad586..0000000000 --- a/osu.Game/Database/IDatabaseContextFactory.cs +++ /dev/null @@ -1,23 +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 - -namespace osu.Game.Database -{ - public interface IDatabaseContextFactory - { - /// - /// Get a context for read-only usage. - /// - OsuDbContext Get(); - - /// - /// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context). - /// This method may block if a write is already active on a different thread. - /// - /// Whether to start a transaction for this write. - /// A usage containing a usable context. - DatabaseWriteUsage GetForWrite(bool withTransaction = true); - } -} diff --git a/osu.Game/Database/IHasRealmFiles.cs b/osu.Game/Database/IHasRealmFiles.cs index 2adfe73d1e..79ea719583 100644 --- a/osu.Game/Database/IHasRealmFiles.cs +++ b/osu.Game/Database/IHasRealmFiles.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Game.Beatmaps; using osu.Game.Models; namespace osu.Game.Database @@ -11,8 +12,16 @@ namespace osu.Game.Database /// public interface IHasRealmFiles { + /// + /// Available files in this model, with locally filenames. + /// When performing lookups, consider using or to do case-insensitive lookups. + /// IList Files { get; } + /// + /// A combined hash representing the model, based on the files it contains. + /// Implementation specific. + /// string Hash { get; set; } } } diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index ebb8be39ef..4085f122d0 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -23,6 +23,16 @@ namespace osu.Game.Database /// The imported models. Task>> Import(ProgressNotification notification, params ImportTask[] tasks); + /// + /// Process a single import as an update for an existing model. + /// This will still run a full import, but perform any post-processing required to make it feel like an update to the user. + /// + /// The notification to update. + /// The import task. + /// The original model which is being updated. + /// The imported model. + Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original); + /// /// A user displayable name for the model type associated with this manager. /// diff --git a/osu.Game/Database/ImportProgressNotification.cs b/osu.Game/Database/ImportProgressNotification.cs index 46f9936bc2..aaee3e117f 100644 --- a/osu.Game/Database/ImportProgressNotification.cs +++ b/osu.Game/Database/ImportProgressNotification.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.Game.Overlays.Notifications; namespace osu.Game.Database diff --git a/osu.Game/Database/LegacyBeatmapImporter.cs b/osu.Game/Database/LegacyBeatmapImporter.cs index 6805cb36b8..0955461609 100644 --- a/osu.Game/Database/LegacyBeatmapImporter.cs +++ b/osu.Game/Database/LegacyBeatmapImporter.cs @@ -20,6 +20,10 @@ namespace osu.Game.Database protected override IEnumerable GetStableImportPaths(Storage storage) { + // make sure the directory exists + if (!storage.ExistsDirectory(string.Empty)) + yield break; + foreach (string directory in storage.GetDirectories(string.Empty)) { var directoryStorage = storage.GetStorageForDirectory(directory); diff --git a/osu.Game/Database/LegacyCollectionImporter.cs b/osu.Game/Database/LegacyCollectionImporter.cs new file mode 100644 index 0000000000..6d3e3fb76a --- /dev/null +++ b/osu.Game/Database/LegacyCollectionImporter.cs @@ -0,0 +1,169 @@ +// 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.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Collections; +using osu.Game.IO.Legacy; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + public class LegacyCollectionImporter + { + public Action? PostNotification { protected get; set; } + + private readonly RealmAccess realm; + + private const string database_name = "collection.db"; + + public LegacyCollectionImporter(RealmAccess realm) + { + this.realm = realm; + } + + public Task GetAvailableCount(Storage storage) + { + if (!storage.Exists(database_name)) + return Task.FromResult(0); + + return Task.Run(() => + { + using (var stream = storage.GetStream(database_name)) + return readCollections(stream).Count; + }); + } + + /// + /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. + /// + public Task ImportFromStorage(Storage storage) + { + if (!storage.Exists(database_name)) + { + // This handles situations like when the user does not have a collections.db file + Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); + return Task.CompletedTask; + } + + return Task.Run(async () => + { + using (var stream = storage.GetStream(database_name)) + await Import(stream).ConfigureAwait(false); + }); + } + + public async Task Import(Stream stream) + { + var notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = "Collections import is initialising..." + }; + + PostNotification?.Invoke(notification); + + var importedCollections = readCollections(stream, notification); + await importCollections(importedCollections).ConfigureAwait(false); + + notification.CompletionText = $"Imported {importedCollections.Count} collections"; + notification.State = ProgressNotificationState.Completed; + } + + private Task importCollections(List newCollections) + { + var tcs = new TaskCompletionSource(); + + try + { + realm.Write(r => + { + foreach (var collection in newCollections) + { + var existing = r.All().FirstOrDefault(c => c.Name == collection.Name); + + if (existing != null) + { + foreach (string newBeatmap in collection.BeatmapMD5Hashes) + { + if (!existing.BeatmapMD5Hashes.Contains(newBeatmap)) + existing.BeatmapMD5Hashes.Add(newBeatmap); + } + } + else + r.Add(collection); + } + }); + + tcs.SetResult(true); + } + catch (Exception e) + { + Logger.Error(e, "Failed to import collection."); + tcs.SetException(e); + } + + return tcs.Task; + } + + private List readCollections(Stream stream, ProgressNotification? notification = null) + { + if (notification != null) + { + notification.Text = "Reading collections..."; + notification.Progress = 0; + } + + var result = new List(); + + try + { + using (var sr = new SerializationReader(stream)) + { + sr.ReadInt32(); // Version + + int collectionCount = sr.ReadInt32(); + result.Capacity = collectionCount; + + for (int i = 0; i < collectionCount; i++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + var collection = new BeatmapCollection(sr.ReadString()); + int mapCount = sr.ReadInt32(); + + for (int j = 0; j < mapCount; j++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + string checksum = sr.ReadString(); + + collection.BeatmapMD5Hashes.Add(checksum); + } + + if (notification != null) + { + notification.Text = $"Imported {i + 1} of {collectionCount} collections"; + notification.Progress = (float)(i + 1) / collectionCount; + } + + result.Add(collection); + } + } + } + catch (Exception e) + { + Logger.Error(e, "Failed to read collection database."); + } + + return result; + } + } +} diff --git a/osu.Game/Database/LegacyImportManager.cs b/osu.Game/Database/LegacyImportManager.cs index f40e0d33c2..78ebd8750e 100644 --- a/osu.Game/Database/LegacyImportManager.cs +++ b/osu.Game/Database/LegacyImportManager.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; using System.Collections.Generic; using System.Threading; @@ -13,7 +11,6 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Game.Beatmaps; -using osu.Game.Collections; using osu.Game.IO; using osu.Game.Overlays; using osu.Game.Overlays.Settings.Sections.Maintenance; @@ -28,27 +25,30 @@ namespace osu.Game.Database public class LegacyImportManager : Component { [Resolved] - private SkinManager skins { get; set; } + private SkinManager skins { get; set; } = null!; [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; [Resolved] - private ScoreManager scores { get; set; } + private ScoreManager scores { get; set; } = null!; [Resolved] - private CollectionManager collections { get; set; } - - [Resolved(canBeNull: true)] - private OsuGame game { get; set; } + private OsuGame? game { get; set; } [Resolved] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay dialogOverlay { get; set; } = null!; - [Resolved(canBeNull: true)] - private DesktopGameHost desktopGameHost { get; set; } + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; - private StableStorage cachedStorage; + [Resolved(canBeNull: true)] // canBeNull required while we remain on mono for mobile platforms. + private DesktopGameHost? desktopGameHost { get; set; } + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + private StableStorage? cachedStorage; public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; @@ -72,7 +72,7 @@ namespace osu.Game.Database return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage); case StableContent.Collections: - return await collections.GetAvailableCount(stableStorage); + return await new LegacyCollectionImporter(realmAccess).GetAvailableCount(stableStorage); case StableContent.Scores: return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage); @@ -99,6 +99,9 @@ namespace osu.Game.Database stableStorage = GetCurrentStableStorage(); } + if (stableStorage == null) + return; + var importTasks = new List(); Task beatmapImportTask = Task.CompletedTask; @@ -109,7 +112,14 @@ namespace osu.Game.Database importTasks.Add(new LegacySkinImporter(skins).ImportFromStableAsync(stableStorage)); if (content.HasFlagFast(StableContent.Collections)) - importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + { + importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyCollectionImporter(realmAccess) + { + // Other legacy importers import via model managers which handle the posting of notifications. + // Collections are an exception. + PostNotification = n => notifications?.Post(n) + }.ImportFromStorage(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + } if (content.HasFlagFast(StableContent.Scores)) importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyScoreImporter(scores).ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); @@ -117,7 +127,7 @@ namespace osu.Game.Database await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false); } - public StableStorage GetCurrentStableStorage() + public StableStorage? GetCurrentStableStorage() { if (cachedStorage != null) return cachedStorage; diff --git a/osu.Game/Database/Live.cs b/osu.Game/Database/Live.cs index e9f99e1e44..3bb11c3a50 100644 --- a/osu.Game/Database/Live.cs +++ b/osu.Game/Database/Live.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; namespace osu.Game.Database { @@ -18,19 +19,19 @@ namespace osu.Game.Database /// Perform a read operation on this live object. /// /// The action to perform. - public abstract void PerformRead(Action perform); + public abstract void PerformRead([InstantHandle] Action perform); /// /// Perform a read operation on this live object. /// /// The action to perform. - public abstract TReturn PerformRead(Func perform); + public abstract TReturn PerformRead([InstantHandle] Func perform); /// /// Perform a write operation on this live object. /// /// The action to perform. - public abstract void PerformWrite(Action perform); + public abstract void PerformWrite([InstantHandle] Action perform); /// /// Whether this instance is tracking data which is managed by the database backing. @@ -50,7 +51,15 @@ namespace osu.Game.Database ID = id; } - public bool Equals(Live? other) => ID == other?.ID; + public bool Equals(Live? other) + { + if (ReferenceEquals(this, other)) return true; + if (other == null) return false; + + return ID == other.ID; + } + + public override int GetHashCode() => HashCode.Combine(ID); public override string ToString() => PerformRead(i => i.ToString()); } diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 76717fd46f..6cba8fe819 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -1,11 +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 System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading.Tasks; using Humanizer; using osu.Framework.Logging; @@ -19,18 +18,18 @@ namespace osu.Game.Database where TModel : class, IHasGuidPrimaryKey, ISoftDelete, IEquatable, T where T : class { - public Action PostNotification { protected get; set; } + public Action? PostNotification { protected get; set; } - public event Action> DownloadBegan; + public event Action>? DownloadBegan; - public event Action> DownloadFailed; + public event Action>? DownloadFailed; private readonly IModelImporter importer; - private readonly IAPIProvider api; + private readonly IAPIProvider? api; protected readonly List> CurrentDownloads = new List>(); - protected ModelDownloader(IModelImporter importer, IAPIProvider api) + protected ModelDownloader(IModelImporter importer, IAPIProvider? api) { this.importer = importer; this.api = api; @@ -44,7 +43,11 @@ namespace osu.Game.Database /// The request object. protected abstract ArchiveDownloadRequest CreateDownloadRequest(T model, bool minimiseDownloadSize); - public bool Download(T model, bool minimiseDownloadSize = false) + public bool Download(T model, bool minimiseDownloadSize = false) => Download(model, minimiseDownloadSize, null); + + public void DownloadAsUpdate(TModel originalModel, bool minimiseDownloadSize) => Download(originalModel, minimiseDownloadSize, originalModel); + + protected bool Download(T model, bool minimiseDownloadSize, TModel? originalModel) { if (!canDownload(model)) return false; @@ -65,11 +68,15 @@ namespace osu.Game.Database { Task.Factory.StartNew(async () => { - // This gets scheduled back to the update thread, but we want the import to run in the background. - var imported = await importer.Import(notification, new ImportTask(filename)).ConfigureAwait(false); + bool importSuccessful; + + if (originalModel != null) + importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel)) != null; + else + importSuccessful = (await importer.Import(notification, new ImportTask(filename))).Any(); // for now a failed import will be marked as a failed download for simplicity. - if (!imported.Any()) + if (!importSuccessful) DownloadFailed?.Invoke(request); CurrentDownloads.Remove(request); @@ -87,7 +94,7 @@ namespace osu.Game.Database CurrentDownloads.Add(request); PostNotification?.Invoke(notification); - api.PerformAsync(request); + api?.PerformAsync(request); DownloadBegan?.Invoke(request); return true; @@ -101,11 +108,19 @@ namespace osu.Game.Database notification.State = ProgressNotificationState.Cancelled; if (!(error is OperationCanceledException)) - Logger.Error(error, $"{importer.HumanisedModelName.Titleize()} download failed!"); + { + if (error is WebException webException && webException.Message == @"TooManyRequests") + { + notification.Close(false); + PostNotification?.Invoke(new TooManyDownloadsNotification()); + } + else + Logger.Error(error, $"{importer.HumanisedModelName.Titleize()} download failed!"); + } } } - public abstract ArchiveDownloadRequest GetExistingDownload(T model); + public abstract ArchiveDownloadRequest? GetExistingDownload(T model); private bool canDownload(T model) => GetExistingDownload(model) == null && api != null; diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs index 4224c92f2c..5c106aa493 100644 --- a/osu.Game/Database/ModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.IO; using System.Linq; using osu.Framework.Platform; +using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Models; using osu.Game.Overlays.Notifications; @@ -79,7 +80,7 @@ namespace osu.Game.Database /// public void AddFile(TModel item, Stream contents, string filename, Realm realm) { - var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + var existing = item.GetFile(filename); if (existing != null) { diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs deleted file mode 100644 index 118a8ee469..0000000000 --- a/osu.Game/Database/OsuDbContext.cs +++ /dev/null @@ -1,214 +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 Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using osu.Framework.Logging; -using osu.Framework.Statistics; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.IO; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Skinning; -using SQLitePCL; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace osu.Game.Database -{ - public class OsuDbContext : DbContext - { - public DbSet EFBeatmapInfo { get; set; } - public DbSet BeatmapDifficulty { get; set; } - public DbSet BeatmapMetadata { get; set; } - public DbSet EFBeatmapSetInfo { get; set; } - public DbSet FileInfo { get; set; } - public DbSet RulesetInfo { get; set; } - public DbSet SkinInfo { get; set; } - public DbSet ScoreInfo { get; set; } - - // migrated to realm - public DbSet DatabasedSetting { get; set; } - - private readonly string connectionString; - - private static readonly Lazy logger = new Lazy(() => new OsuDbLoggerFactory()); - - private static readonly GlobalStatistic contexts = GlobalStatistics.Get("Database", "Contexts"); - - static OsuDbContext() - { - // required to initialise native SQLite libraries on some platforms. - Batteries_V2.Init(); - - // https://github.com/aspnet/EntityFrameworkCore/issues/9994#issuecomment-508588678 - raw.sqlite3_config(2 /*SQLITE_CONFIG_MULTITHREAD*/); - } - - /// - /// Create a new in-memory OsuDbContext instance. - /// - public OsuDbContext() - : this("DataSource=:memory:") - { - // required for tooling (see https://wildermuth.com/2017/07/06/Program-cs-in-ASP-NET-Core-2-0). - - Migrate(); - } - - /// - /// Create a new OsuDbContext instance. - /// - /// A valid SQLite connection string. - public OsuDbContext(string connectionString) - { - this.connectionString = connectionString; - - var connection = Database.GetDbConnection(); - - try - { - connection.Open(); - - using (var cmd = connection.CreateCommand()) - { - cmd.CommandText = "PRAGMA journal_mode=WAL;"; - cmd.ExecuteNonQuery(); - - cmd.CommandText = "PRAGMA foreign_keys=OFF;"; - cmd.ExecuteNonQuery(); - } - } - catch - { - connection.Close(); - throw; - } - - contexts.Value++; - } - - ~OsuDbContext() - { - // DbContext does not contain a finalizer (https://github.com/aspnet/EntityFrameworkCore/issues/8872) - // This is used to clean up previous contexts when fresh contexts are exposed via DatabaseContextFactory - Dispose(); - } - - private bool isDisposed; - - public override void Dispose() - { - if (isDisposed) return; - - isDisposed = true; - - base.Dispose(); - - contexts.Value--; - GC.SuppressFinalize(this); - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - base.OnConfiguring(optionsBuilder); - optionsBuilder - // this is required for the time being due to the way we are querying in places like BeatmapStore. - // if we ever move to having consumers file their own .Includes, or get eager loading support, this could be re-enabled. - .UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(10)) - .UseLoggerFactory(logger.Value); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.MD5Hash); - modelBuilder.Entity().HasIndex(b => b.Hash); - - modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.DeletePending); - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.DeletePending); - modelBuilder.Entity().HasMany(s => s.Files).WithOne(f => f.SkinInfo); - - modelBuilder.Entity().HasIndex(b => new { b.RulesetID, b.Variant }); - - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.ReferenceCount); - - modelBuilder.Entity().HasIndex(b => b.Available); - modelBuilder.Entity().HasIndex(b => b.ShortName).IsUnique(); - - modelBuilder.Entity().HasOne(b => b.BaseDifficulty); - - modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); - } - - private class OsuDbLoggerFactory : ILoggerFactory - { - #region Disposal - - public void Dispose() - { - } - - #endregion - - public ILogger CreateLogger(string categoryName) => new OsuDbLogger(); - - public void AddProvider(ILoggerProvider provider) - { - // no-op. called by tooling. - } - - private class OsuDbLogger : ILogger - { - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) - { - if (logLevel < LogLevel.Information) - return; - - Framework.Logging.LogLevel frameworkLogLevel; - - switch (logLevel) - { - default: - frameworkLogLevel = Framework.Logging.LogLevel.Debug; - break; - - case LogLevel.Warning: - frameworkLogLevel = Framework.Logging.LogLevel.Important; - break; - - case LogLevel.Error: - case LogLevel.Critical: - frameworkLogLevel = Framework.Logging.LogLevel.Error; - break; - } - - Logger.Log(formatter(state, exception), LoggingTarget.Database, frameworkLogLevel); - } - - public bool IsEnabled(LogLevel logLevel) - { -#if DEBUG_DATABASE - return logLevel > LogLevel.Debug; -#else - return logLevel > LogLevel.Information; -#endif - } - - public IDisposable BeginScope(TState state) => null; - } - } - - public void Migrate() => Database.Migrate(); - } -} diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 8cf57b802b..edcd020226 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Development; +using osu.Framework.Extensions; using osu.Framework.Input.Bindings; using osu.Framework.Logging; using osu.Framework.Platform; @@ -23,7 +24,9 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Models; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; using Realms; @@ -43,8 +46,6 @@ namespace osu.Game.Database /// public readonly string Filename; - private readonly IDatabaseContextFactory? efContextFactory; - private readonly SynchronizationContext? updateThreadSyncContext; /// @@ -58,8 +59,19 @@ namespace osu.Game.Database /// 12 2021-11-24 Add Status to RealmBeatmapSet. /// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields). /// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo. + /// 15 2022-07-13 Added LastPlayed to BeatmapInfo. + /// 16 2022-07-15 Removed HasReplay from ScoreInfo. + /// 17 2022-07-16 Added CountryCode to RealmUser. + /// 18 2022-07-19 Added OnlineMD5Hash and LastOnlineUpdate to BeatmapInfo. + /// 19 2022-07-19 Added DateSubmitted and DateRanked to BeatmapSetInfo. + /// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1. + /// 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. + /// 25 2022-09-18 Remove skins to add with new naming. /// - private const int schema_version = 14; + private const int schema_version = 25; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -150,11 +162,9 @@ namespace osu.Game.Database /// The game storage which will be used to create the realm backing file. /// The filename to use for the realm backing file. A ".realm" extension will be added automatically if not specified. /// The game update thread, used to post realm operations into a thread-safe context. - /// An EF factory used only for migration purposes. - public RealmAccess(Storage storage, string filename, GameThread? updateThread = null, IDatabaseContextFactory? efContextFactory = null) + public RealmAccess(Storage storage, string filename, GameThread? updateThread = null) { this.storage = storage; - this.efContextFactory = efContextFactory; updateThreadSyncContext = updateThread?.SynchronizationContext ?? SynchronizationContext.Current; @@ -163,6 +173,11 @@ namespace osu.Game.Database if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; +#if DEBUG + if (!DebugUtils.IsNUnitRunning) + applyFilenameSchemaSuffix(ref Filename); +#endif + string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; // Attempt to recover a newer database version if available. @@ -202,6 +217,51 @@ namespace osu.Game.Database } } + /// + /// Some developers may be annoyed if a newer version migration (ie. caused by testing a pull request) + /// cause their test database to be unusable with previous versions. + /// To get around this, store development databases against their realm version. + /// Note that this means changes made on newer realm versions will disappear. + /// + private void applyFilenameSchemaSuffix(ref string filename) + { + string originalFilename = filename; + + filename = getVersionedFilename(schema_version); + + // First check if the current realm version already exists... + if (storage.Exists(filename)) + return; + + // Check for a previous version we can use as a base database to migrate from... + for (int i = schema_version - 1; i >= 0; i--) + { + string previousFilename = getVersionedFilename(i); + + if (storage.Exists(previousFilename)) + { + copyPreviousVersion(previousFilename, filename); + return; + } + } + + // Finally, check for a non-versioned file exists (aka before this method was added)... + if (storage.Exists(originalFilename)) + copyPreviousVersion(originalFilename, filename); + + void copyPreviousVersion(string previousFilename, string newFilename) + { + using (var previous = storage.GetStream(previousFilename)) + using (var current = storage.CreateFileSafely(newFilename)) + { + Logger.Log(@$"Copying previous realm database {previousFilename} to {newFilename} for migration to schema version {schema_version}"); + previous.CopyTo(current); + } + } + + string getVersionedFilename(int version) => originalFilename.Replace(realm_extension, $"_{version}{realm_extension}"); + } + private void attemptRecoverFromFile(string recoveryFilename) { Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database); @@ -272,7 +332,6 @@ namespace osu.Game.Database realm.Remove(score); realm.Remove(beatmap.Metadata); - realm.Remove(beatmap); } @@ -284,6 +343,11 @@ namespace osu.Game.Database foreach (var s in pendingDeleteSkins) realm.Remove(s); + var pendingDeletePresets = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeletePresets) + realm.Remove(s); + transaction.Commit(); } @@ -775,12 +839,57 @@ namespace osu.Game.Database case 14: foreach (var beatmap in migration.NewRealm.All()) beatmap.UserSettings = new BeatmapUserSettings(); + + break; + + case 20: + // As we now have versioned difficulty calculations, let's reset + // all star ratings and have `BackgroundBeatmapProcessor` recalculate them. + foreach (var beatmap in migration.NewRealm.All()) + beatmap.StarRating = -1; + + break; + + case 21: + // Migrate collections from external file to inside realm. + // We use the "legacy" importer because that is how things were actually being saved out until now. + var legacyCollectionImporter = new LegacyCollectionImporter(this); + + if (legacyCollectionImporter.GetAvailableCount(storage).GetResultSafely() > 0) + { + legacyCollectionImporter.ImportFromStorage(storage).ContinueWith(task => + { + if (task.Exception != null) + { + // can be removed 20221027 (just for initial safety). + Logger.Error(task.Exception.InnerException, "Collections could not be migrated to realm. Please provide your \"collection.db\" to the dev team."); + return; + } + + storage.Move("collection.db", "collection.db.migrated"); + }); + } + + break; + + case 25: + // Remove the default skins so they can be added back by SkinManager with updated naming. + migration.NewRealm.RemoveRange(migration.NewRealm.All().Where(s => s.Protected)); break; } } - private string? getRulesetShortNameFromLegacyID(long rulesetId) => - efContextFactory?.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; + private string? getRulesetShortNameFromLegacyID(long rulesetId) + { + try + { + return new APIBeatmap.APIRuleset { OnlineID = (int)rulesetId }.ShortName; + } + catch + { + return null; + } + } /// /// Create a full realm backup. @@ -805,8 +914,15 @@ namespace osu.Game.Database try { using (var source = storage.GetStream(Filename, mode: FileMode.Open)) - using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) - source.CopyTo(destination); + { + // source may not exist. + if (source == null) + return; + + using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) + source.CopyTo(destination); + } + return; } catch (IOException) diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index 6cea92f1d9..0286815569 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -108,47 +108,42 @@ namespace osu.Game.Database bool isBatchImport = tasks.Length >= minimum_items_considered_batch_import; - try + await Task.WhenAll(tasks.Select(async task => { - await Task.WhenAll(tasks.Select(async task => + if (notification.CancellationToken.IsCancellationRequested) + return; + + try { - notification.CancellationToken.ThrowIfCancellationRequested(); + var model = await Import(task, isBatchImport, notification.CancellationToken).ConfigureAwait(false); - try + lock (imported) { - var model = await Import(task, isBatchImport, notification.CancellationToken).ConfigureAwait(false); + if (model != null) + imported.Add(model); + current++; - lock (imported) - { - if (model != null) - imported.Add(model); - current++; + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + })).ConfigureAwait(false); - notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; - notification.Progress = (float)current / tasks.Length; - } - } - catch (TaskCanceledException) - { - throw; - } - catch (Exception e) - { - Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); - } - })).ConfigureAwait(false); - } - catch (OperationCanceledException) + if (imported.Count == 0) { - if (imported.Count == 0) + if (notification.CancellationToken.IsCancellationRequested) { notification.State = ProgressNotificationState.Cancelled; return imported; } - } - if (imported.Count == 0) - { notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!"; notification.State = ProgressNotificationState.Cancelled; } @@ -174,6 +169,8 @@ namespace osu.Game.Database return imported; } + public virtual Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original) => throw new NotImplementedException(); + /// /// Import one from the filesystem and delete the file on success. /// Note that this bypasses the UI flow and should only be used for special cases or testing. @@ -297,15 +294,38 @@ namespace osu.Game.Database // Log output here will be missing a valid hash in non-batch imports. LogForModel(item, $@"Beginning import from {archive?.Name ?? "unknown"}..."); + List files = new List(); + + if (archive != null) + { + // Import files to the disk store. + // We intentionally delay adding to realm to avoid blocking on a write during disk operations. + foreach (var filenames in getShortenedFilenames(archive)) + { + using (Stream s = archive.GetStream(filenames.original)) + files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false), filenames.shortened)); + } + } + + using (var transaction = realm.BeginWrite()) + { + // Add all files to realm in one go. + // This is done ahead of the main transaction to ensure we can correctly cleanup the files, even if the import fails. + foreach (var file in files) + { + if (!file.File.IsManaged) + realm.Add(file.File, true); + } + + transaction.Commit(); + } + + item.Files.AddRange(files); + item.Hash = ComputeHash(item); + // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. using (var transaction = realm.BeginWrite()) { - if (archive != null) - // TODO: look into rollback of file additions (or delayed commit). - item.Files.AddRange(createFileInfos(archive, Files, realm)); - - item.Hash = ComputeHash(item); - // TODO: we may want to run this outside of the transaction. Populate(item, archive, realm, cancellationToken); @@ -338,11 +358,11 @@ namespace osu.Game.Database // import to store realm.Add(item); + PostImport(item, realm, batchImport); + transaction.Commit(); } - PostImport(item, realm); - LogForModel(item, @"Import successfully completed!"); } catch (Exception e) @@ -428,16 +448,6 @@ namespace osu.Game.Database { var fileInfos = new List(); - // import files to manager - foreach (var filenames in getShortenedFilenames(reader)) - { - using (Stream s = reader.GetStream(filenames.original)) - { - var item = new RealmNamedFileUsage(files.Add(s, realm), filenames.shortened); - fileInfos.Add(item); - } - } - return fileInfos; } @@ -479,11 +489,12 @@ namespace osu.Game.Database } /// - /// Perform any final actions after the import has been committed to the database. + /// Perform any final actions before the import has been committed to the database. /// /// The model prepared for import. /// The current realm context. - protected virtual void PostImport(TModel model, Realm realm) + /// Whether the import was part of a batch. + protected virtual void PostImport(TModel model, Realm realm, bool batchImport) { } diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index ecb152c45c..036b15ea17 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -40,8 +40,8 @@ namespace osu.Game.Database /// /// The file data stream. /// The realm instance to add to. Should already be in a transaction. - /// - public RealmFile Add(Stream data, Realm realm) + /// Whether the should immediately be added to the underlying realm. If false is provided here, the instance must be manually added. + public RealmFile Add(Stream data, Realm realm, bool addToRealm = true) { string hash = data.ComputeSHA2Hash(); @@ -52,7 +52,7 @@ namespace osu.Game.Database if (!checkFileExistsAndMatchesHash(file)) copyToStore(file, data); - if (!file.IsManaged) + if (addToRealm && !file.IsManaged) realm.Add(file); return file; diff --git a/osu.Game/Database/TooManyDownloadsNotification.cs b/osu.Game/Database/TooManyDownloadsNotification.cs new file mode 100644 index 0000000000..14012e1d34 --- /dev/null +++ b/osu.Game/Database/TooManyDownloadsNotification.cs @@ -0,0 +1,26 @@ +// 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.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Overlays.Notifications; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Database +{ + public class TooManyDownloadsNotification : SimpleNotification + { + public TooManyDownloadsNotification() + { + Text = BeatmapsetsStrings.DownloadLimitExceeded; + Icon = FontAwesome.Solid.ExclamationCircle; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + IconContent.Colour = colours.RedDark; + } + } +} diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index d1aba2bfe3..35f2d61437 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using Humanizer; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -67,7 +66,7 @@ namespace osu.Game.Extensions foreach (var (_, property) in component.GetSettingsSourceProperties()) { - if (!info.Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + if (!info.Settings.TryGetValue(property.Name.ToSnakeCase(), out object settingValue)) continue; skinnable.CopyAdjustedSetting((IBindable)property.GetValue(component), settingValue); diff --git a/osu.Game/Extensions/StringDehumanizeExtensions.cs b/osu.Game/Extensions/StringDehumanizeExtensions.cs new file mode 100644 index 0000000000..6f0d7622d3 --- /dev/null +++ b/osu.Game/Extensions/StringDehumanizeExtensions.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. + +// Based on code from the Humanizer library (https://github.com/Humanizr/Humanizer/blob/606e958cb83afc9be5b36716ac40d4daa9fa73a7/src/Humanizer/InflectorExtensions.cs) +// +// Humanizer is licenced under the MIT License (MIT) +// +// Copyright (c) .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +using System.Text.RegularExpressions; + +namespace osu.Game.Extensions +{ + /// + /// Class with extension methods used to turn human-readable strings to casing conventions frequently used in code. + /// Often used for communicating with other systems (web API, spectator server). + /// All of the operations in this class are intentionally culture-invariant. + /// + public static class StringDehumanizeExtensions + { + /// + /// Converts the string to "Pascal case" (also known as "upper camel case"). + /// + /// + /// + /// "this is a test string".ToPascalCase() == "ThisIsATestString" + /// + /// + public static string ToPascalCase(this string input) + { + return Regex.Replace(input, "(?:^|_|-| +)(.)", match => match.Groups[1].Value.ToUpperInvariant()); + } + + /// + /// Converts the string to (lower) "camel case". + /// + /// + /// + /// "this is a test string".ToCamelCase() == "thisIsATestString" + /// + /// + public static string ToCamelCase(this string input) + { + string word = input.ToPascalCase(); + return word.Length > 0 ? word.Substring(0, 1).ToLowerInvariant() + word.Substring(1) : word; + } + + /// + /// Converts the string to "snake case". + /// + /// + /// + /// "this is a test string".ToSnakeCase() == "this_is_a_test_string" + /// + /// + public static string ToSnakeCase(this string input) + { + return Regex.Replace( + Regex.Replace( + Regex.Replace(input, @"([\p{Lu}]+)([\p{Lu}][\p{Ll}])", "$1_$2"), @"([\p{Ll}\d])([\p{Lu}])", "$1_$2"), @"[-\s]", "_").ToLowerInvariant(); + } + + /// + /// Converts the string to "kebab case". + /// + /// + /// + /// "this is a test string".ToKebabCase() == "this-is-a-test-string" + /// + /// + public static string ToKebabCase(this string input) + { + return ToSnakeCase(input).Replace('_', '-'); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Background.cs b/osu.Game/Graphics/Backgrounds/Background.cs index 2dd96bcb09..0899c0706d 100644 --- a/osu.Game/Graphics/Backgrounds/Background.cs +++ b/osu.Game/Graphics/Backgrounds/Background.cs @@ -57,7 +57,7 @@ namespace osu.Game.Graphics.Backgrounds { if (bufferedContainer == null && newBlurSigma != Vector2.Zero) { - RemoveInternal(Sprite); + RemoveInternal(Sprite, false); AddInternal(bufferedContainer = new BufferedContainer(cachedFrameBuffer: true) { diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs index 99af95b5fe..5c98e22818 100644 --- a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -38,21 +38,19 @@ namespace osu.Game.Graphics.Backgrounds private void load(OsuConfigManager config, SessionStatics sessionStatics) { seasonalBackgroundMode = config.GetBindable(OsuSetting.SeasonalBackgroundMode); - seasonalBackgroundMode.BindValueChanged(_ => triggerSeasonalBackgroundChanged()); + seasonalBackgroundMode.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); seasonalBackgrounds = sessionStatics.GetBindable(Static.SeasonalBackgrounds); - seasonalBackgrounds.BindValueChanged(_ => triggerSeasonalBackgroundChanged()); + seasonalBackgrounds.BindValueChanged(_ => + { + if (shouldShowSeasonal) + SeasonalBackgroundChanged?.Invoke(); + }); apiState.BindTo(api.State); apiState.BindValueChanged(fetchSeasonalBackgrounds, true); } - private void triggerSeasonalBackgroundChanged() - { - if (shouldShowSeasonal) - SeasonalBackgroundChanged?.Invoke(); - } - private void fetchSeasonalBackgrounds(ValueChangedEvent stateChanged) { if (seasonalBackgrounds.Value != null || stateChanged.NewValue != APIState.Online) diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index b83df45bd7..1166a86814 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -14,9 +14,8 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Primitives; using osu.Framework.Allocation; using System.Collections.Generic; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.OpenGL.Buffers; -using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Lists; namespace osu.Game.Graphics.Backgrounds @@ -88,7 +87,7 @@ namespace osu.Game.Graphics.Backgrounds private Random stableRandom; private IShader shader; - private readonly Texture texture; + private Texture texture; /// /// Construct a new triangle visualisation. @@ -98,13 +97,12 @@ namespace osu.Game.Graphics.Backgrounds { if (seed != null) stableRandom = new Random(seed.Value); - - texture = Texture.WhitePixel; } [BackgroundDependencyLoader] - private void load(ShaderManager shaders) + private void load(IRenderer renderer, ShaderManager shaders) { + texture = renderer.WhitePixel; shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); } @@ -184,8 +182,8 @@ namespace osu.Game.Graphics.Backgrounds private void addTriangles(bool randomY) { - // limited by the maximum size of QuadVertexBuffer for safety. - const int max_triangles = QuadVertexBuffer.MAX_QUADS; + // Limited by the maximum size of QuadVertexBuffer for safety. + const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2); AimCount = (int)Math.Min(max_triangles, (DrawWidth * DrawHeight * 0.002f / (triangleScale * triangleScale) * SpawnRatio)); @@ -251,7 +249,7 @@ namespace osu.Game.Graphics.Backgrounds private readonly List parts = new List(); private Vector2 size; - private QuadBatch vertexBatch; + private IVertexBatch vertexBatch; public TrianglesDrawNode(Triangles source) : base(source) @@ -270,14 +268,14 @@ namespace osu.Game.Graphics.Backgrounds parts.AddRange(Source.parts); } - public override void Draw(Action vertexAction) + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); if (Source.AimCount > 0 && (vertexBatch == null || vertexBatch.Size != Source.AimCount)) { vertexBatch?.Dispose(); - vertexBatch = new QuadBatch(Source.AimCount, 1); + vertexBatch = renderer.CreateQuadBatch(Source.AimCount, 1); } shader.Bind(); @@ -297,7 +295,7 @@ namespace osu.Game.Graphics.Backgrounds ColourInfo colourInfo = DrawColourInfo.Colour; colourInfo.ApplyChild(particle.Colour); - DrawTriangle( + renderer.DrawTriangle( texture, triangle, colourInfo, diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 4b40add87f..00fea601c6 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.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; using System.Diagnostics; using osu.Framework.Allocation; @@ -10,13 +8,12 @@ using osu.Framework.Audio.Track; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Screens.Play; namespace osu.Game.Graphics.Containers { /// /// A container which fires a callback when a new beat is reached. - /// Consumes a parent or (whichever is first available). + /// Consumes a parent . /// /// /// This container does not set its own clock to the source used for beat matching. @@ -28,7 +25,10 @@ namespace osu.Game.Graphics.Containers public class BeatSyncedContainer : Container { private int lastBeat; - private TimingControlPoint lastTimingPoint; + + private TimingControlPoint? lastTimingPoint { get; set; } + + protected bool IsKiaiTime { get; private set; } /// /// The amount of time before a beat we should fire . @@ -70,12 +70,12 @@ namespace osu.Game.Graphics.Containers public double MinimumBeatLength { get; set; } /// - /// Whether this container is currently tracking a beatmap's timing data. + /// Whether this container is currently tracking a beat sync provider. /// protected bool IsBeatSyncedWithTrack { get; private set; } [Resolved] - protected IBeatSyncProvider BeatSyncSource { get; private set; } + protected IBeatSyncProvider BeatSyncSource { get; private set; } = null!; protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { @@ -86,19 +86,18 @@ namespace osu.Game.Graphics.Containers TimingControlPoint timingPoint; EffectControlPoint effectPoint; - IsBeatSyncedWithTrack = BeatSyncSource.Clock?.IsRunning == true && BeatSyncSource.ControlPoints != null; + IsBeatSyncedWithTrack = BeatSyncSource.CheckBeatSyncAvailable() && BeatSyncSource.Clock?.IsRunning == true; double currentTrackTime; if (IsBeatSyncedWithTrack) { - Debug.Assert(BeatSyncSource.ControlPoints != null); Debug.Assert(BeatSyncSource.Clock != null); currentTrackTime = BeatSyncSource.Clock.CurrentTime + EarlyActivationMilliseconds; - timingPoint = BeatSyncSource.ControlPoints.TimingPointAt(currentTrackTime); - effectPoint = BeatSyncSource.ControlPoints.EffectPointAt(currentTrackTime); + timingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT; + effectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT; } else { @@ -135,11 +134,13 @@ namespace osu.Game.Graphics.Containers if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE) { using (BeginDelayedSequence(-TimeSinceLastBeat)) - OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.Amplitudes ?? ChannelAmplitudes.Empty); + OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.CurrentAmplitudes); } lastBeat = beatIndex; lastTimingPoint = timingPoint; + + IsKiaiTime = effectPoint.KiaiMode; } } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs index a2370a76cb..3855ed6d4e 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs @@ -4,7 +4,10 @@ #nullable disable using Markdig; -using Markdig.Extensions.AutoIdentifiers; +using Markdig.Extensions.AutoLinks; +using Markdig.Extensions.CustomContainers; +using Markdig.Extensions.EmphasisExtras; +using Markdig.Extensions.Footnotes; using Markdig.Extensions.Tables; using Markdig.Extensions.Yaml; using Markdig.Syntax; @@ -18,6 +21,24 @@ namespace osu.Game.Graphics.Containers.Markdown { public class OsuMarkdownContainer : MarkdownContainer { + /// + /// Allows this markdown container to parse and link footnotes. + /// + /// + protected virtual bool Footnotes => false; + + /// + /// Allows this markdown container to make URL text clickable. + /// + /// + protected virtual bool Autolinks => false; + + /// + /// Allows this markdown container to parse custom containers (used for flags and infoboxes). + /// + /// + protected virtual bool CustomContainers => false; + public OsuMarkdownContainer() { LineSpacing = 21; @@ -78,10 +99,25 @@ namespace osu.Game.Graphics.Containers.Markdown return new OsuMarkdownUnorderedListItem(level); } + // reference: https://github.com/ppy/osu-web/blob/05488a96b25b5a09f2d97c54c06dd2bae59d1dc8/app/Libraries/Markdown/OsuMarkdown.php#L301 protected override MarkdownPipeline CreateBuilder() - => new MarkdownPipelineBuilder().UseAutoIdentifiers(AutoIdentifierOptions.GitHub) - .UseEmojiAndSmiley() - .UseYamlFrontMatter() - .UseAdvancedExtensions().Build(); + { + var pipeline = new MarkdownPipelineBuilder() + .UseAutoIdentifiers() + .UsePipeTables() + .UseEmphasisExtras(EmphasisExtraOptions.Strikethrough) + .UseYamlFrontMatter(); + + if (Footnotes) + pipeline = pipeline.UseFootnotes(); + + if (Autolinks) + pipeline = pipeline.UseAutoLinks(); + + if (CustomContainers) + pipeline.UseCustomContainers(); + + return pipeline.Build(); + } } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs index db8abfb269..9d7b47281f 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs @@ -3,6 +3,9 @@ #nullable disable +using System; +using System.Linq; +using Markdig.Extensions.CustomContainers; using Markdig.Syntax.Inlines; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,6 +14,9 @@ using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Graphics.Containers.Markdown { @@ -33,6 +39,31 @@ namespace osu.Game.Graphics.Containers.Markdown protected override SpriteText CreateEmphasisedSpriteText(bool bold, bool italic) => CreateSpriteText().With(t => t.Font = t.Font.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, italics: italic)); + protected override void AddCustomComponent(CustomContainerInline inline) + { + if (!(inline.FirstChild is LiteralInline literal)) + { + base.AddCustomComponent(inline); + return; + } + + string[] attributes = literal.Content.ToString().Trim(' ', '{', '}').Split(); + string flagAttribute = attributes.SingleOrDefault(a => a.StartsWith(@"flag", StringComparison.Ordinal)); + + if (flagAttribute == null) + { + base.AddCustomComponent(inline); + return; + } + + string flag = flagAttribute.Split('=').Last().Trim('"'); + + if (!Enum.TryParse(flag, out var countryCode)) + countryCode = CountryCode.Unknown; + + AddDrawable(new DrawableFlag(countryCode) { Size = new Vector2(20, 15) }); + } + private class OsuMarkdownInlineCode : Container { [Resolved] diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index c04a5add89..97e9ff88b5 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Graphics.Containers if (value == expandableHeader) return; if (expandableHeader != null) - RemoveInternal(expandableHeader); + RemoveInternal(expandableHeader, false); expandableHeader = value; @@ -55,6 +55,7 @@ namespace osu.Game.Graphics.Containers fixedHeader?.Expire(); fixedHeader = value; + if (value == null) return; AddInternal(fixedHeader); @@ -70,8 +71,10 @@ namespace osu.Game.Graphics.Containers if (value == footer) return; if (footer != null) - scrollContainer.Remove(footer); + scrollContainer.Remove(footer, false); + footer = value; + if (value == null) return; footer.Anchor |= Anchor.y2; diff --git a/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs b/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs index 94505d2310..2667b8b8e0 100644 --- a/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs +++ b/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs @@ -59,7 +59,7 @@ namespace osu.Game.Graphics.Containers drawable.StateChanged += state => selectionChanged(drawable, state); } - public override bool Remove(T drawable) + public override bool Remove(T drawable, bool disposeImmediately) => throw new NotSupportedException($"Cannot remove drawables from {nameof(SelectionCycleFillFlowContainer)}"); private void setSelected(int? value) diff --git a/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs new file mode 100644 index 0000000000..300b5bd4b4 --- /dev/null +++ b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs @@ -0,0 +1,119 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Graphics.Containers +{ + /// + /// A container that reverts any rotation (and optionally scale) applied by its direct parent. + /// + public class UprightAspectMaintainingContainer : Container + { + /// + /// Controls how much this container scales compared to its parent (default is 1.0f). + /// + public float ScalingFactor { get; set; } = 1; + + /// + /// Controls the scaling of this container. + /// + public ScaleMode Scaling { get; set; } = ScaleMode.Vertical; + + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawInfo, InvalidationSource.Parent); + + public UprightAspectMaintainingContainer() + { + AddLayout(layout); + } + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid) + { + keepUprightAndUnstretched(); + layout.Validate(); + } + } + + /// + /// Keeps the drawable upright and unstretched preventing it from being rotated, sheared, scaled or flipped with its Parent. + /// + private void keepUprightAndUnstretched() + { + // Decomposes the inverse of the parent DrawInfo.Matrix into rotation, shear and scale. + var parentMatrix = Parent.DrawInfo.Matrix; + + // Remove Translation.> + parentMatrix.M31 = 0.0f; + parentMatrix.M32 = 0.0f; + + Matrix3 reversedParent = parentMatrix.Inverted(); + + // Extract the rotation. + float angle = MathF.Atan2(reversedParent.M12, reversedParent.M11); + Rotation = MathHelper.RadiansToDegrees(angle); + + // Remove rotation from the C matrix so that it only contains shear and scale. + Matrix3 m = Matrix3.CreateRotationZ(-angle); + reversedParent *= m; + + // Extract shear. + float alpha = reversedParent.M21 / reversedParent.M22; + Shear = new Vector2(-alpha, 0); + + // Etract scale. + float sx = reversedParent.M11; + float sy = reversedParent.M22; + + Vector3 parentScale = parentMatrix.ExtractScale(); + + float usedScale = 1.0f; + + switch (Scaling) + { + case ScaleMode.Horizontal: + usedScale = parentScale.X; + break; + + case ScaleMode.Vertical: + usedScale = parentScale.Y; + break; + } + + if (Scaling != ScaleMode.NoScaling) + { + if (ScalingFactor < 1.0f) + usedScale = 1.0f + (usedScale - 1.0f) * ScalingFactor; + if (ScalingFactor > 1.0f) + usedScale = (usedScale < 1.0f) ? usedScale * (1.0f / ScalingFactor) : usedScale * ScalingFactor; + } + + Scale = new Vector2(sx * usedScale, sy * usedScale); + } + } + + public enum ScaleMode + { + /// + /// Prevent this container from scaling. + /// + NoScaling, + + /// + /// Scale uniformly (maintaining aspect ratio) based on the vertical scale of the parent. + /// + Vertical, + + /// + /// Scale uniformly (maintaining aspect ratio) based on the horizontal scale of the parent. + /// + Horizontal, + } +} diff --git a/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs new file mode 100644 index 0000000000..f5429723be --- /dev/null +++ b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Input; +using osu.Framework.Input.StateChanges; +using osu.Game.Configuration; + +namespace osu.Game.Graphics.Cursor +{ + /// + /// A container which provides the main . + /// Also handles cases where a more localised cursor is provided by another component (via ). + /// + public class GlobalCursorDisplay : Container, IProvideCursor + { + /// + /// Control whether any cursor should be displayed. + /// + internal bool ShowCursor = true; + + CursorContainer IProvideCursor.Cursor => MenuCursor; + + public MenuCursorContainer MenuCursor { get; } + + public bool ProvidingUserCursor => true; + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private Bindable showDuringTouch = null!; + + private InputManager inputManager = null!; + + private IProvideCursor? currentOverrideProvider; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + public GlobalCursorDisplay() + { + AddRangeInternal(new Drawable[] + { + Content = new Container { RelativeSizeAxes = Axes.Both }, + MenuCursor = new MenuCursorContainer { State = { Value = Visibility.Hidden } } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + showDuringTouch = config.GetBindable(OsuSetting.GameplayCursorDuringTouch); + } + + protected override void Update() + { + base.Update(); + + var lastMouseSource = inputManager.CurrentState.Mouse.LastSource; + bool hasValidInput = lastMouseSource != null && (showDuringTouch.Value || lastMouseSource is not ISourcedFromTouch); + + if (!hasValidInput || !ShowCursor) + { + currentOverrideProvider?.Cursor?.Hide(); + currentOverrideProvider = null; + return; + } + + IProvideCursor newOverrideProvider = this; + + foreach (var d in inputManager.HoveredDrawables) + { + if (d is IProvideCursor p && p.ProvidingUserCursor) + { + newOverrideProvider = p; + break; + } + } + + if (currentOverrideProvider == newOverrideProvider) + return; + + currentOverrideProvider?.Cursor?.Hide(); + newOverrideProvider.Cursor?.Show(); + + currentOverrideProvider = newOverrideProvider; + } + } +} diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs deleted file mode 100644 index 10ed76ebdd..0000000000 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ /dev/null @@ -1,204 +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 osuTK; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; -using System; -using JetBrains.Annotations; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; -using osu.Framework.Input.Events; -using osu.Framework.Utils; - -namespace osu.Game.Graphics.Cursor -{ - public class MenuCursor : CursorContainer - { - private readonly IBindable screenshotCursorVisibility = new Bindable(true); - public override bool IsPresent => screenshotCursorVisibility.Value && base.IsPresent; - - protected override Drawable CreateCursor() => activeCursor = new Cursor(); - - private Cursor activeCursor; - - private Bindable cursorRotate; - private DragRotationState dragRotationState; - private Vector2 positionMouseDown; - - private Sample tapSample; - - [BackgroundDependencyLoader(true)] - private void load([NotNull] OsuConfigManager config, [CanBeNull] ScreenshotManager screenshotManager, AudioManager audio) - { - cursorRotate = config.GetBindable(OsuSetting.CursorRotation); - - if (screenshotManager != null) - screenshotCursorVisibility.BindTo(screenshotManager.CursorVisibility); - - tapSample = audio.Samples.Get(@"UI/cursor-tap"); - } - - protected override bool OnMouseMove(MouseMoveEvent e) - { - if (dragRotationState != DragRotationState.NotDragging) - { - // make the rotation centre point floating. - if (Vector2.Distance(positionMouseDown, e.MousePosition) > 60) - positionMouseDown = Interpolation.ValueAt(0.005f, positionMouseDown, e.MousePosition, 0, Clock.ElapsedFrameTime); - - var position = e.MousePosition; - float distance = Vector2Extensions.Distance(position, positionMouseDown); - - // don't start rotating until we're moved a minimum distance away from the mouse down location, - // else it can have an annoying effect. - if (dragRotationState == DragRotationState.DragStarted && distance > 80) - dragRotationState = DragRotationState.Rotating; - - // don't rotate when distance is zero to avoid NaN - if (dragRotationState == DragRotationState.Rotating && distance > 0) - { - Vector2 offset = e.MousePosition - positionMouseDown; - float degrees = MathUtils.RadiansToDegrees(MathF.Atan2(-offset.X, offset.Y)) + 24.3f; - - // Always rotate in the direction of least distance - float diff = (degrees - activeCursor.Rotation) % 360; - if (diff < -180) diff += 360; - if (diff > 180) diff -= 360; - degrees = activeCursor.Rotation + diff; - - activeCursor.RotateTo(degrees, 120, Easing.OutQuint); - } - } - - return base.OnMouseMove(e); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (State.Value == Visibility.Visible) - { - // only trigger animation for main mouse buttons - activeCursor.Scale = new Vector2(1); - activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - - activeCursor.AdditiveLayer.Alpha = 0; - activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - - if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) - { - // if cursor is already rotating don't reset its rotate origin - dragRotationState = DragRotationState.DragStarted; - positionMouseDown = e.MousePosition; - } - - playTapSample(); - } - - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - if (!e.HasAnyButtonPressed) - { - activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); - activeCursor.ScaleTo(1, 500, Easing.OutElastic); - - if (dragRotationState != DragRotationState.NotDragging) - { - activeCursor.RotateTo(0, 400 * (0.5f + Math.Abs(activeCursor.Rotation / 960)), Easing.OutElasticQuarter); - dragRotationState = DragRotationState.NotDragging; - } - - if (State.Value == Visibility.Visible) - playTapSample(0.8); - } - - base.OnMouseUp(e); - } - - protected override void PopIn() - { - activeCursor.FadeTo(1, 250, Easing.OutQuint); - activeCursor.ScaleTo(1, 400, Easing.OutQuint); - } - - protected override void PopOut() - { - activeCursor.FadeTo(0, 250, Easing.OutQuint); - activeCursor.ScaleTo(0.6f, 250, Easing.In); - } - - private void playTapSample(double baseFrequency = 1f) - { - const float random_range = 0.02f; - SampleChannel channel = tapSample.GetChannel(); - - // Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird) - channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * 0.75; - channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range); - channel.Volume.Value = baseFrequency; - - channel.Play(); - } - - public class Cursor : Container - { - private Container cursorContainer; - private Bindable cursorScale; - private const float base_scale = 0.15f; - - public Sprite AdditiveLayer; - - public Cursor() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config, TextureStore textures, OsuColour colour) - { - Children = new Drawable[] - { - cursorContainer = new Container - { - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Sprite - { - Texture = textures.Get(@"Cursor/menu-cursor"), - }, - AdditiveLayer = new Sprite - { - Blending = BlendingParameters.Additive, - Colour = colour.Pink, - Alpha = 0, - Texture = textures.Get(@"Cursor/menu-cursor-additive"), - }, - } - } - }; - - cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); - cursorScale.BindValueChanged(scale => cursorContainer.Scale = new Vector2(scale.NewValue * base_scale), true); - } - } - - private enum DragRotationState - { - NotDragging, - DragStarted, - Rotating, - } - } -} diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index 746f1b5d5e..af542989ff 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs @@ -1,83 +1,303 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Input; -using osu.Framework.Input.StateChanges; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osuTK; namespace osu.Game.Graphics.Cursor { - /// - /// A container which provides a which can be overridden by hovered s. - /// - public class MenuCursorContainer : Container, IProvideCursor + public class MenuCursorContainer : CursorContainer { - protected override Container Content => content; - private readonly Container content; + private readonly IBindable screenshotCursorVisibility = new Bindable(true); + public override bool IsPresent => screenshotCursorVisibility.Value && base.IsPresent; - /// - /// Whether any cursors can be displayed. - /// - internal bool CanShowCursor = true; + private bool hideCursorOnNonMouseInput; - public CursorContainer Cursor { get; } - public bool ProvidingUserCursor => true; - - public MenuCursorContainer() + public bool HideCursorOnNonMouseInput { - AddRangeInternal(new Drawable[] + get => hideCursorOnNonMouseInput; + set { - Cursor = new MenuCursor { State = { Value = Visibility.Hidden } }, - content = new Container { RelativeSizeAxes = Axes.Both } - }); + if (hideCursorOnNonMouseInput == value) + return; + + hideCursorOnNonMouseInput = value; + updateState(); + } } - private InputManager inputManager; + protected override Drawable CreateCursor() => activeCursor = new Cursor(); + + private Cursor activeCursor = null!; + + private DragRotationState dragRotationState; + private Vector2 positionMouseDown; + private Vector2 lastMovePosition; + + private Bindable cursorRotate = null!; + private Sample tapSample = null!; + + private MouseInputDetector mouseInputDetector = null!; + + private bool visible; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, ScreenshotManager? screenshotManager, AudioManager audio) + { + cursorRotate = config.GetBindable(OsuSetting.CursorRotation); + + if (screenshotManager != null) + screenshotCursorVisibility.BindTo(screenshotManager.CursorVisibility); + + tapSample = audio.Samples.Get(@"UI/cursor-tap"); + + Add(mouseInputDetector = new MouseInputDetector()); + } + + [Resolved] + private OsuGame? game { get; set; } + + private readonly IBindable lastInputWasMouse = new BindableBool(); + private readonly IBindable isIdle = new BindableBool(); protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); + + lastInputWasMouse.BindTo(mouseInputDetector.LastInputWasMouseSource); + lastInputWasMouse.BindValueChanged(_ => updateState(), true); + + if (game != null) + { + isIdle.BindTo(game.IsIdle); + isIdle.BindValueChanged(_ => updateState()); + } } - private IProvideCursor currentTarget; + protected override void UpdateState(ValueChangedEvent state) => updateState(); + + private void updateState() + { + bool combinedVisibility = State.Value == Visibility.Visible && (lastInputWasMouse.Value || !hideCursorOnNonMouseInput) && !isIdle.Value; + + if (visible == combinedVisibility) + return; + + visible = combinedVisibility; + + if (visible) + PopIn(); + else + PopOut(); + } protected override void Update() { base.Update(); - var lastMouseSource = inputManager.CurrentState.Mouse.LastSource; - bool hasValidInput = lastMouseSource != null && !(lastMouseSource is ISourcedFromTouch); - - if (!hasValidInput || !CanShowCursor) + if (dragRotationState != DragRotationState.NotDragging + && Vector2.Distance(positionMouseDown, lastMovePosition) > 60) { - currentTarget?.Cursor?.Hide(); - currentTarget = null; - return; + // make the rotation centre point floating. + positionMouseDown = Interpolation.ValueAt(0.04f, positionMouseDown, lastMovePosition, 0, Clock.ElapsedFrameTime); } + } - IProvideCursor newTarget = this; - - foreach (var d in inputManager.HoveredDrawables) + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (dragRotationState != DragRotationState.NotDragging) { - if (d is IProvideCursor p && p.ProvidingUserCursor) + lastMovePosition = e.MousePosition; + + float distance = Vector2Extensions.Distance(lastMovePosition, positionMouseDown); + + // don't start rotating until we're moved a minimum distance away from the mouse down location, + // else it can have an annoying effect. + if (dragRotationState == DragRotationState.DragStarted && distance > 80) + dragRotationState = DragRotationState.Rotating; + + // don't rotate when distance is zero to avoid NaN + if (dragRotationState == DragRotationState.Rotating && distance > 0) { - newTarget = p; - break; + Vector2 offset = e.MousePosition - positionMouseDown; + float degrees = MathUtils.RadiansToDegrees(MathF.Atan2(-offset.X, offset.Y)) + 24.3f; + + // Always rotate in the direction of least distance + float diff = (degrees - activeCursor.Rotation) % 360; + if (diff < -180) diff += 360; + if (diff > 180) diff -= 360; + degrees = activeCursor.Rotation + diff; + + activeCursor.RotateTo(degrees, 120, Easing.OutQuint); } } - if (currentTarget == newTarget) - return; + return base.OnMouseMove(e); + } - currentTarget?.Cursor?.Hide(); - newTarget.Cursor?.Show(); + protected override bool OnMouseDown(MouseDownEvent e) + { + if (State.Value == Visibility.Visible) + { + // only trigger animation for main mouse buttons + activeCursor.Scale = new Vector2(1); + activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - currentTarget = newTarget; + activeCursor.AdditiveLayer.Alpha = 0; + activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); + + if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) + { + // if cursor is already rotating don't reset its rotate origin + dragRotationState = DragRotationState.DragStarted; + positionMouseDown = e.MousePosition; + } + + playTapSample(); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (!e.HasAnyButtonPressed) + { + activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); + activeCursor.ScaleTo(1, 500, Easing.OutElastic); + + if (dragRotationState != DragRotationState.NotDragging) + { + activeCursor.RotateTo(0, 400 * (0.5f + Math.Abs(activeCursor.Rotation / 960)), Easing.OutElasticQuarter); + dragRotationState = DragRotationState.NotDragging; + } + + if (State.Value == Visibility.Visible) + playTapSample(0.8); + } + + base.OnMouseUp(e); + } + + protected override void PopIn() + { + activeCursor.FadeTo(1, 250, Easing.OutQuint); + activeCursor.ScaleTo(1, 400, Easing.OutQuint); + } + + protected override void PopOut() + { + activeCursor.FadeTo(0, 250, Easing.OutQuint); + activeCursor.ScaleTo(0.6f, 250, Easing.In); + } + + private void playTapSample(double baseFrequency = 1f) + { + const float random_range = 0.02f; + SampleChannel channel = tapSample.GetChannel(); + + // Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird) + channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * 0.75; + channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range); + channel.Volume.Value = baseFrequency; + + channel.Play(); + } + + public class Cursor : Container + { + private Container cursorContainer = null!; + private Bindable cursorScale = null!; + private const float base_scale = 0.15f; + + public Sprite AdditiveLayer = null!; + + public Cursor() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, TextureStore textures, OsuColour colour) + { + Children = new Drawable[] + { + cursorContainer = new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Sprite + { + Texture = textures.Get(@"Cursor/menu-cursor"), + }, + AdditiveLayer = new Sprite + { + Blending = BlendingParameters.Additive, + Colour = colour.Pink, + Alpha = 0, + Texture = textures.Get(@"Cursor/menu-cursor-additive"), + }, + } + } + }; + + cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); + cursorScale.BindValueChanged(scale => cursorContainer.Scale = new Vector2(scale.NewValue * base_scale), true); + } + } + + private class MouseInputDetector : Component + { + /// + /// Whether the last input applied to the game is sourced from mouse. + /// + public IBindable LastInputWasMouseSource => lastInputWasMouseSource; + + private readonly Bindable lastInputWasMouseSource = new Bindable(); + + public MouseInputDetector() + { + RelativeSizeAxes = Axes.Both; + } + + protected override bool Handle(UIEvent e) + { + switch (e) + { + case MouseDownEvent: + case MouseMoveEvent: + lastInputWasMouseSource.Value = true; + return false; + + case KeyDownEvent keyDown when !keyDown.Repeat: + case JoystickPressEvent: + case MidiDownEvent: + lastInputWasMouseSource.Value = false; + return false; + } + + return false; + } + } + + private enum DragRotationState + { + NotDragging, + DragStarted, + Rotating, } } } diff --git a/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs b/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs index a53b665857..78c8cbb79e 100644 --- a/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs +++ b/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs @@ -5,7 +5,7 @@ using System; using System.Runtime.InteropServices; -using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Rendering.Vertices; using osuTK; using osuTK.Graphics; using osuTK.Graphics.ES30; diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 0e411876d3..91161d5c71 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -102,26 +102,31 @@ namespace osu.Game.Graphics /// /// Retrieves the colour for a . /// - public Color4 ForHitResult(HitResult judgement) + public Color4 ForHitResult(HitResult result) { - switch (judgement) + switch (result) { - case HitResult.Perfect: - case HitResult.Great: - return Blue; - - case HitResult.Ok: - case HitResult.Good: - return Green; + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: + case HitResult.Miss: + return Red; case HitResult.Meh: return Yellow; - case HitResult.Miss: - return Red; + case HitResult.Ok: + return Green; + + case HitResult.Good: + return GreenLight; + + case HitResult.SmallTickHit: + case HitResult.LargeTickHit: + case HitResult.Great: + return Blue; default: - return Color4.White; + return BlueLight; } } @@ -137,6 +142,9 @@ namespace osu.Game.Graphics { switch (status) { + case BeatmapOnlineStatus.LocallyModified: + return Color4.OrangeRed; + case BeatmapOnlineStatus.Ranked: case BeatmapOnlineStatus.Approved: return Color4Extensions.FromHex(@"b3ff66"); diff --git a/osu.Game/Graphics/ParticleExplosion.cs b/osu.Game/Graphics/ParticleExplosion.cs index c6f9c1343b..a902c8426f 100644 --- a/osu.Game/Graphics/ParticleExplosion.cs +++ b/osu.Game/Graphics/ParticleExplosion.cs @@ -6,8 +6,8 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; @@ -89,7 +89,7 @@ namespace osu.Game.Graphics currentTime = source.Time.Current; } - protected override void Blit(Action vertexAction) + protected override void Blit(IRenderer renderer) { double time = currentTime - startTime; @@ -112,9 +112,9 @@ namespace osu.Game.Graphics Vector2Extensions.Transform(rect.BottomRight, DrawInfo.Matrix) ); - DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, - new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), - null, TextureCoords); + renderer.DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), + inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + textureCoords: TextureCoords); } } diff --git a/osu.Game/Graphics/ParticleSpewer.cs b/osu.Game/Graphics/ParticleSpewer.cs index 06c9882d72..41a7d82e74 100644 --- a/osu.Game/Graphics/ParticleSpewer.cs +++ b/osu.Game/Graphics/ParticleSpewer.cs @@ -7,8 +7,8 @@ using System; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; @@ -107,7 +107,7 @@ namespace osu.Game.Graphics sourceSize = Source.DrawSize; } - protected override void Blit(Action vertexAction) + protected override void Blit(IRenderer renderer) { foreach (var p in particles) { @@ -136,9 +136,9 @@ namespace osu.Game.Graphics transformPosition(rect.BottomRight, rect.Centre, angle) ); - DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, - new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), - null, TextureCoords); + renderer.DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), + inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + textureCoords: TextureCoords); } } diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs index 13733bb3ad..7debdc7a37 100644 --- a/osu.Game/Graphics/Sprites/LogoAnimation.cs +++ b/osu.Game/Graphics/Sprites/LogoAnimation.cs @@ -3,10 +3,9 @@ #nullable disable -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Sprites; @@ -57,11 +56,11 @@ namespace osu.Game.Graphics.Sprites progress = source.animationProgress; } - protected override void Blit(Action vertexAction) + protected override void Blit(IRenderer renderer) { - Shader.GetUniform("progress").UpdateValue(ref progress); + GetAppropriateShader(renderer).GetUniform("progress").UpdateValue(ref progress); - base.Blit(vertexAction); + base.Blit(renderer); } protected override bool CanDrawOpaqueInterior => false; diff --git a/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs b/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs new file mode 100644 index 0000000000..baffe106ed --- /dev/null +++ b/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs @@ -0,0 +1,108 @@ +// 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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.Sprites +{ + /// + /// A wrapped version of which will expand in size based on text content, but never shrink back down. + /// + public class SizePreservingSpriteText : CompositeDrawable + { + private readonly OsuSpriteText text = new OsuSpriteText(); + + private Vector2 maximumSize; + + public SizePreservingSpriteText(Vector2? minimumSize = null) + { + text.Origin = Anchor.Centre; + text.Anchor = Anchor.Centre; + + AddInternal(text); + maximumSize = minimumSize ?? Vector2.Zero; + } + + protected override void Update() + { + Width = maximumSize.X = MathF.Max(maximumSize.X, text.Width); + Height = maximumSize.Y = MathF.Max(maximumSize.Y, text.Height); + } + + public new Axes AutoSizeAxes + { + get => Axes.None; + set => throw new InvalidOperationException("You can't set AutoSizeAxes of this container"); + } + + /// + /// Gets or sets the text to be displayed. + /// + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + /// + /// Contains information on the font used to display the text. + /// + public FontUsage Font + { + get => text.Font; + set => text.Font = value; + } + + /// + /// True if a shadow should be displayed around the text. + /// + public bool Shadow + { + get => text.Shadow; + set => text.Shadow = value; + } + + /// + /// The colour of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. + /// + public Color4 ShadowColour + { + get => text.ShadowColour; + set => text.ShadowColour = value; + } + + /// + /// The offset of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. + /// + public Vector2 ShadowOffset + { + get => text.ShadowOffset; + set => text.ShadowOffset = value; + } + + /// + /// True if the 's vertical size should be equal to (the full height) or precisely the size of used characters. + /// Set to false to allow better centering of individual characters/numerals/etc. + /// + public bool UseFullGlyphHeight + { + get => text.UseFullGlyphHeight; + set => text.UseFullGlyphHeight = value; + } + + public override bool IsPresent => text.IsPresent; + + public override string ToString() => text.ToString(); + + public float LineBaseHeight => text.LineBaseHeight; + + public IEnumerable FilterTerms => text.FilterTerms; + } +} diff --git a/osu.Game/Graphics/UserInterface/BarGraph.cs b/osu.Game/Graphics/UserInterface/BarGraph.cs index f55875ac58..2e9fd6734f 100644 --- a/osu.Game/Graphics/UserInterface/BarGraph.cs +++ b/osu.Game/Graphics/UserInterface/BarGraph.cs @@ -74,7 +74,7 @@ namespace osu.Game.Graphics.UserInterface } //I'm using ToList() here because Where() returns an Enumerable which can change it's elements afterwards - RemoveRange(Children.Where((_, index) => index >= value.Count()).ToList()); + RemoveRange(Children.Where((_, index) => index >= value.Count()).ToList(), true); } } } diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index ae286f5092..e51dbeed14 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -1,33 +1,38 @@ // 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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Overlays; +using osu.Game.Overlays.OSD; using osuTK; using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class ExternalLinkButton : CompositeDrawable, IHasTooltip + public class ExternalLinkButton : CompositeDrawable, IHasTooltip, IHasContextMenu { - public string Link { get; set; } + public string? Link { get; set; } private Color4 hoverColour; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; + + [Resolved] + private OnScreenDisplay? onScreenDisplay { get; set; } private readonly SpriteIcon linkIcon; - public ExternalLinkButton(string link = null) + public ExternalLinkButton(string? link = null) { Link = link; Size = new Vector2(12); @@ -68,5 +73,27 @@ namespace osu.Game.Graphics.UserInterface } public LocalisableString TooltipText => "view in browser"; + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List(); + + if (Link != null) + { + items.Add(new OsuMenuItem("Open", MenuItemType.Standard, () => host.OpenUrlExternally(Link))); + items.Add(new OsuMenuItem("Copy URL", MenuItemType.Standard, copyUrl)); + } + + return items.ToArray(); + } + } + + private void copyUrl() + { + host.GetClipboard()?.SetText(Link); + onScreenDisplay?.Display(new CopyUrlToast()); + } } } diff --git a/osu.Game/Graphics/UserInterface/FPSCounter.cs b/osu.Game/Graphics/UserInterface/FPSCounter.cs new file mode 100644 index 0000000000..e9fb6a046a --- /dev/null +++ b/osu.Game/Graphics/UserInterface/FPSCounter.cs @@ -0,0 +1,284 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public class FPSCounter : VisibilityContainer, IHasCustomTooltip + { + private OsuSpriteText counterUpdateFrameTime = null!; + private OsuSpriteText counterDrawFPS = null!; + + private Container mainContent = null!; + + private Container background = null!; + + private Container counters = null!; + + private const double min_time_between_updates = 10; + + private const double spike_time_ms = 20; + + private const float idle_background_alpha = 0.4f; + + private readonly BindableBool showFpsDisplay = new BindableBool(true); + + private double displayedFpsCount; + private double displayedFrameTime; + + private bool isDisplayed; + + private double aimDrawFPS; + private double aimUpdateFPS; + + private double lastUpdate; + private ThrottledFrameClock drawClock = null!; + private ThrottledFrameClock updateClock = null!; + private ThrottledFrameClock inputClock = null!; + + /// + /// The last time value where the display was required (due to a significant change or hovering). + /// + private double lastDisplayRequiredTime; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public FPSCounter() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, GameHost gameHost) + { + InternalChildren = new Drawable[] + { + mainContent = new Container + { + Alpha = 0, + Height = 26, + Children = new Drawable[] + { + background = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + CornerExponent = 5f, + Masking = true, + Alpha = idle_background_alpha, + Children = new Drawable[] + { + new Box + { + Colour = colours.Gray0, + RelativeSizeAxes = Axes.Both, + }, + } + }, + counters = new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + counterUpdateFrameTime = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(1), + Font = OsuFont.Default.With(fixedWidth: true, size: 16, weight: FontWeight.SemiBold), + Spacing = new Vector2(-1), + Y = -2, + }, + counterDrawFPS = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(2), + Font = OsuFont.Default.With(fixedWidth: true, size: 13, weight: FontWeight.SemiBold), + Spacing = new Vector2(-2), + Y = 10, + } + } + }, + } + }, + }; + + config.BindWith(OsuSetting.ShowFpsDisplay, showFpsDisplay); + + drawClock = gameHost.DrawThread.Clock; + updateClock = gameHost.UpdateThread.Clock; + inputClock = gameHost.InputThread.Clock; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + requestDisplay(); + + showFpsDisplay.BindValueChanged(showFps => + { + State.Value = showFps.NewValue ? Visibility.Visible : Visibility.Hidden; + if (showFps.NewValue) + requestDisplay(); + }, true); + + State.BindValueChanged(state => showFpsDisplay.Value = state.NewValue == Visibility.Visible); + } + + protected override void PopIn() => this.FadeIn(100); + + protected override void PopOut() => this.FadeOut(100); + + protected override bool OnHover(HoverEvent e) + { + background.FadeTo(1, 200); + requestDisplay(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeTo(idle_background_alpha, 200); + requestDisplay(); + base.OnHoverLost(e); + } + + protected override void Update() + { + base.Update(); + + // If the game goes into a suspended state (ie. debugger attached or backgrounded on a mobile device) + // we want to ignore really long periods of no processing. + if (updateClock.ElapsedFrameTime > 10000) + return; + + mainContent.Width = Math.Max(mainContent.Width, counters.DrawWidth); + + // Handle the case where the window has become inactive or the user changed the + // frame limiter (we want to show the FPS as it's changing, even if it isn't an outlier). + bool aimRatesChanged = updateAimFPS(); + + bool hasUpdateSpike = displayedFrameTime < spike_time_ms && updateClock.ElapsedFrameTime > spike_time_ms; + // use elapsed frame time rather then FramesPerSecond to better catch stutter frames. + bool hasDrawSpike = displayedFpsCount > (1000 / spike_time_ms) && drawClock.ElapsedFrameTime > spike_time_ms; + + const float damp_time = 100; + + displayedFrameTime = Interpolation.DampContinuously(displayedFrameTime, updateClock.ElapsedFrameTime, hasUpdateSpike ? 0 : damp_time, updateClock.ElapsedFrameTime); + + if (hasDrawSpike) + // show spike time using raw elapsed value, to account for `FramesPerSecond` being so averaged spike frames don't show. + displayedFpsCount = 1000 / drawClock.ElapsedFrameTime; + else + displayedFpsCount = Interpolation.DampContinuously(displayedFpsCount, drawClock.FramesPerSecond, damp_time, Time.Elapsed); + + if (Time.Current - lastUpdate > min_time_between_updates) + { + updateFpsDisplay(); + updateFrameTimeDisplay(); + + lastUpdate = Time.Current; + } + + bool hasSignificantChanges = aimRatesChanged + || hasDrawSpike + || hasUpdateSpike + || displayedFpsCount < aimDrawFPS * 0.8 + || 1000 / displayedFrameTime < aimUpdateFPS * 0.8; + + if (hasSignificantChanges) + requestDisplay(); + else if (isDisplayed && Time.Current - lastDisplayRequiredTime > 2000 && !IsHovered) + { + mainContent.FadeTo(0, 300, Easing.OutQuint); + isDisplayed = false; + } + } + + private void requestDisplay() + { + lastDisplayRequiredTime = Time.Current; + + if (!isDisplayed) + { + mainContent.FadeTo(1, 300, Easing.OutQuint); + isDisplayed = true; + } + } + + private void updateFpsDisplay() + { + counterDrawFPS.Colour = getColour(displayedFpsCount / aimDrawFPS); + counterDrawFPS.Text = $"{displayedFpsCount:#,0}fps"; + } + + private void updateFrameTimeDisplay() + { + counterUpdateFrameTime.Text = displayedFrameTime < 5 + ? $"{displayedFrameTime:N1}ms" + : $"{displayedFrameTime:N0}ms"; + + counterUpdateFrameTime.Colour = getColour((1000 / displayedFrameTime) / aimUpdateFPS); + } + + private bool updateAimFPS() + { + if (updateClock.Throttling) + { + double newAimDrawFPS = drawClock.MaximumUpdateHz; + double newAimUpdateFPS = updateClock.MaximumUpdateHz; + + if (aimDrawFPS != newAimDrawFPS || aimUpdateFPS != newAimUpdateFPS) + { + aimDrawFPS = newAimDrawFPS; + aimUpdateFPS = newAimUpdateFPS; + return true; + } + } + else + { + double newAimFPS = inputClock.MaximumUpdateHz; + + if (aimDrawFPS != newAimFPS || aimUpdateFPS != newAimFPS) + { + aimUpdateFPS = aimDrawFPS = newAimFPS; + return true; + } + } + + return false; + } + + private ColourInfo getColour(double performanceRatio) + { + if (performanceRatio < 0.5f) + return Interpolation.ValueAt(performanceRatio, colours.Red, colours.Orange2, 0, 0.5); + + return Interpolation.ValueAt(performanceRatio, colours.Orange2, colours.Lime0, 0.5, 0.9); + } + + public ITooltip GetCustomTooltip() => new FPSCounterTooltip(); + + public object TooltipContent => this; + } +} diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs new file mode 100644 index 0000000000..bf53bff9b4 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public class FPSCounterTooltip : CompositeDrawable, ITooltip + { + private OsuTextFlowContainer textFlow = null!; + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Both; + + CornerRadius = 15; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray1, + Alpha = 1, + RelativeSizeAxes = Axes.Both, + }, + new OsuTextFlowContainer(cp => + { + cp.Font = OsuFont.Default.With(weight: FontWeight.SemiBold); + }) + { + AutoSizeAxes = Axes.Both, + TextAnchor = Anchor.TopRight, + Margin = new MarginPadding { Left = 5, Vertical = 10 }, + Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)) + }, + textFlow = new OsuTextFlowContainer(cp => + { + cp.Font = OsuFont.Default.With(fixedWidth: true, weight: FontWeight.Regular); + cp.Spacing = new Vector2(-1); + }) + { + Width = 190, + Margin = new MarginPadding { Left = 35, Right = 10, Vertical = 10 }, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopRight, + }, + }; + } + + private int lastUpdate; + + protected override void Update() + { + int currentSecond = (int)(Clock.CurrentTime / 100); + + if (currentSecond != lastUpdate) + { + lastUpdate = currentSecond; + + textFlow.Clear(); + + foreach (var thread in gameHost.Threads) + { + var clock = thread.Clock; + + string maximum = clock.Throttling + ? $"/{(clock.MaximumUpdateHz > 0 && clock.MaximumUpdateHz < 10000 ? clock.MaximumUpdateHz.ToString("0") : "∞"),4}" + : string.Empty; + + textFlow.AddParagraph($"{clock.FramesPerSecond:0}{maximum}fps ({clock.ElapsedFrameTime:0.00}ms)"); + } + } + } + + public void SetContent(object content) + { + } + + public void Move(Vector2 pos) + { + Position = pos; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index fce4221c87..0c18fd36fc 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -21,6 +21,11 @@ namespace osu.Game.Graphics.UserInterface private bool allowImmediateFocus => host?.OnScreenKeyboardOverlapsGameWindow != true; + /// + /// Whether the content of the text box should be cleared on the first "back" key press. + /// + protected virtual bool ClearTextOnBackKey => true; + public void TakeFocus() { if (!allowImmediateFocus) @@ -78,11 +83,12 @@ namespace osu.Game.Graphics.UserInterface if (!HasFocus) return false; - if (e.Action == GlobalAction.Back) + if (ClearTextOnBackKey && e.Action == GlobalAction.Back) { if (Text.Length > 0) { Text = string.Empty; + PlayFeedbackSample(FeedbackSampleType.TextRemove); return true; } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index 6358317e9d..f0ff76b35d 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -15,6 +15,9 @@ namespace osu.Game.Graphics.UserInterface [Description("button")] Button, + [Description("button-sidebar")] + ButtonSidebar, + [Description("toolbar")] Toolbar, diff --git a/osu.Game/Graphics/UserInterface/LoadingLayer.cs b/osu.Game/Graphics/UserInterface/LoadingLayer.cs index b3655eaab4..9f6177c226 100644 --- a/osu.Game/Graphics/UserInterface/LoadingLayer.cs +++ b/osu.Game/Graphics/UserInterface/LoadingLayer.cs @@ -20,6 +20,8 @@ namespace osu.Game.Graphics.UserInterface /// public class LoadingLayer : LoadingSpinner { + private readonly bool blockInput; + [CanBeNull] protected Box BackgroundDimLayer { get; } @@ -28,9 +30,11 @@ namespace osu.Game.Graphics.UserInterface /// /// Whether the full background area should be dimmed while loading. /// Whether the spinner should have a surrounding black box for visibility. - public LoadingLayer(bool dimBackground = false, bool withBox = true) + /// Whether to block input of components behind the loading layer. + public LoadingLayer(bool dimBackground = false, bool withBox = true, bool blockInput = true) : base(withBox) { + this.blockInput = blockInput; RelativeSizeAxes = Axes.Both; Size = new Vector2(1); @@ -52,6 +56,9 @@ namespace osu.Game.Graphics.UserInterface protected override bool Handle(UIEvent e) { + if (!blockInput) + return false; + switch (e) { // blocking scroll can cause weird behaviour when this layer is used within a ScrollContainer. @@ -83,7 +90,7 @@ namespace osu.Game.Graphics.UserInterface { base.Update(); - MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 30, 100)); + MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 20, 100)); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index c48627bd21..9acb0c7f94 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -44,6 +44,8 @@ namespace osu.Game.Graphics.UserInterface public virtual LocalisableString TooltipText { get; private set; } + public bool PlaySamplesOnAdjust { get; set; } = true; + /// /// Whether to format the tooltip as a percentage or the actual value. /// @@ -187,6 +189,9 @@ namespace osu.Game.Graphics.UserInterface private void playSample(T value) { + if (!PlaySamplesOnAdjust) + return; + if (Clock == null || Clock.CurrentTime - lastSampleTime <= 30) return; @@ -228,10 +233,8 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - LeftBox.Scale = new Vector2(Math.Clamp( - RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, DrawWidth), 1); - RightBox.Scale = new Vector2(Math.Clamp( - DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2, 0, DrawWidth), 1); + LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2, 0, Math.Max(0, DrawWidth)), 1); } protected override void UpdateValue(float value) diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 1650d2b274..18977638f3 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.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 System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -40,14 +42,27 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Left = 2 }, }; - private readonly Sample?[] textAddedSamples = new Sample[4]; - private Sample? capsTextAddedSample; - private Sample? textRemovedSample; - private Sample? textCommittedSample; - private Sample? caretMovedSample; - private OsuCaret? caret; + private bool selectionStarted; + private double sampleLastPlaybackTime; + + protected enum FeedbackSampleType + { + TextAdd, + TextAddCaps, + TextRemove, + TextConfirm, + TextInvalid, + CaretMove, + SelectCharacter, + SelectWord, + SelectAll, + Deselect + } + + private Dictionary sampleMap = new Dictionary(); + public OsuTextBox() { Height = 40; @@ -71,13 +86,23 @@ namespace osu.Game.Graphics.UserInterface Placeholder.Colour = colourProvider?.Foreground1 ?? new Color4(180, 180, 180, 255); + var textAddedSamples = new Sample?[4]; for (int i = 0; i < textAddedSamples.Length; i++) textAddedSamples[i] = audio.Samples.Get($@"Keyboard/key-press-{1 + i}"); - capsTextAddedSample = audio.Samples.Get(@"Keyboard/key-caps"); - textRemovedSample = audio.Samples.Get(@"Keyboard/key-delete"); - textCommittedSample = audio.Samples.Get(@"Keyboard/key-confirm"); - caretMovedSample = audio.Samples.Get(@"Keyboard/key-movement"); + sampleMap = new Dictionary + { + { FeedbackSampleType.TextAdd, textAddedSamples }, + { FeedbackSampleType.TextAddCaps, new[] { audio.Samples.Get(@"Keyboard/key-caps") } }, + { FeedbackSampleType.TextRemove, new[] { audio.Samples.Get(@"Keyboard/key-delete") } }, + { FeedbackSampleType.TextConfirm, new[] { audio.Samples.Get(@"Keyboard/key-confirm") } }, + { FeedbackSampleType.TextInvalid, new[] { audio.Samples.Get(@"Keyboard/key-invalid") } }, + { FeedbackSampleType.CaretMove, new[] { audio.Samples.Get(@"Keyboard/key-movement") } }, + { FeedbackSampleType.SelectCharacter, new[] { audio.Samples.Get(@"Keyboard/select-char") } }, + { FeedbackSampleType.SelectWord, new[] { audio.Samples.Get(@"Keyboard/select-word") } }, + { FeedbackSampleType.SelectAll, new[] { audio.Samples.Get(@"Keyboard/select-all") } }, + { FeedbackSampleType.Deselect, new[] { audio.Samples.Get(@"Keyboard/deselect") } } + }; } private Color4 selectionColour; @@ -88,31 +113,75 @@ namespace osu.Game.Graphics.UserInterface { base.OnUserTextAdded(added); + if (!added.Any(CanAddCharacter)) + return; + if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) - capsTextAddedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextAddCaps); else - playTextAddedSample(); + PlayFeedbackSample(FeedbackSampleType.TextAdd); } protected override void OnUserTextRemoved(string removed) { base.OnUserTextRemoved(removed); - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); + } + + protected override void NotifyInputError() + { + base.NotifyInputError(); + + PlayFeedbackSample(FeedbackSampleType.TextInvalid); } protected override void OnTextCommitted(bool textChanged) { base.OnTextCommitted(textChanged); - textCommittedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextConfirm); } protected override void OnCaretMoved(bool selecting) { base.OnCaretMoved(selecting); - caretMovedSample?.Play(); + if (!selecting) + PlayFeedbackSample(FeedbackSampleType.CaretMove); + } + + protected override void OnTextSelectionChanged(TextSelectionType selectionType) + { + base.OnTextSelectionChanged(selectionType); + + switch (selectionType) + { + case TextSelectionType.Character: + PlayFeedbackSample(FeedbackSampleType.SelectCharacter); + break; + + case TextSelectionType.Word: + PlayFeedbackSample(selectionStarted ? FeedbackSampleType.SelectCharacter : FeedbackSampleType.SelectWord); + break; + + case TextSelectionType.All: + PlayFeedbackSample(FeedbackSampleType.SelectAll); + break; + } + + selectionStarted = true; + } + + protected override void OnTextDeselected() + { + base.OnTextDeselected(); + + if (!selectionStarted) return; + + PlayFeedbackSample(FeedbackSampleType.Deselect); + + selectionStarted = false; } protected override void OnImeComposition(string newComposition, int removedTextLength, int addedTextLength, bool caretMoved) @@ -129,13 +198,13 @@ namespace osu.Game.Graphics.UserInterface case 1: // composition probably ended by pressing backspace, or was cancelled. - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); return; default: // longer text removed, composition ended because it was cancelled. // could be a different sample if desired. - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); return; } } @@ -143,7 +212,7 @@ namespace osu.Game.Graphics.UserInterface if (addedTextLength > 0) { // some text was added, probably due to typing new text or by changing the candidate. - playTextAddedSample(); + PlayFeedbackSample(FeedbackSampleType.TextAdd); return; } @@ -151,14 +220,14 @@ namespace osu.Game.Graphics.UserInterface { // text was probably removed by backspacing. // it's also possible that a candidate that only removed text was changed to. - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); return; } if (caretMoved) { // only the caret/selection was moved. - caretMovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.CaretMove); } } @@ -169,13 +238,13 @@ namespace osu.Game.Graphics.UserInterface if (successful) { // composition was successfully completed, usually by pressing the enter key. - textCommittedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextConfirm); } else { // composition was prematurely ended, eg. by clicking inside the textbox. // could be a different sample if desired. - textCommittedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextConfirm); } } @@ -204,7 +273,34 @@ namespace osu.Game.Graphics.UserInterface SelectionColour = SelectionColour, }; - private void playTextAddedSample() => textAddedSamples[RNG.Next(0, textAddedSamples.Length)]?.Play(); + private SampleChannel? getSampleChannel(FeedbackSampleType feedbackSampleType) + { + var samples = sampleMap[feedbackSampleType]; + + if (samples == null || samples.Length == 0) + return null; + + return samples[RNG.Next(0, samples.Length)]?.GetChannel(); + } + + protected void PlayFeedbackSample(FeedbackSampleType feedbackSample) => Schedule(() => + { + if (Time.Current < sampleLastPlaybackTime + 15) return; + + SampleChannel? channel = getSampleChannel(feedbackSample); + + if (channel == null) return; + + double pitch = 0.98 + RNG.NextDouble(0.04); + + if (feedbackSample == FeedbackSampleType.SelectCharacter) + pitch += ((double)SelectedText.Length / Math.Max(1, Text.Length)) * 0.15f; + + channel.Frequency.Value = pitch; + channel.Play(); + + sampleLastPlaybackTime = Time.Current; + }); private class OsuCaret : Caret { diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index 259c0646f3..0c25d06cd4 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.cs @@ -97,7 +97,7 @@ namespace osu.Game.Graphics.UserInterface { backgroundLayer = new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Y, CornerRadius = corner_radius, Masking = true, BorderThickness = 2, @@ -128,10 +128,12 @@ namespace osu.Game.Graphics.UserInterface if (width != null) { Width = width.Value; + backgroundLayer.RelativeSizeAxes = Axes.Both; } else { AutoSizeAxes = Axes.X; + backgroundLayer.AutoSizeAxes = Axes.X; text.Margin = new MarginPadding { Horizontal = 15 }; } } diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs index 0bbcb2b976..9ef09d799e 100644 --- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs @@ -49,15 +49,15 @@ namespace osu.Game.Graphics.UserInterface Active.BindDisabledChanged(disabled => Action = disabled ? null : Active.Toggle, true); Active.BindValueChanged(_ => { - updateActiveState(); + UpdateActiveState(); playSample(); }); - updateActiveState(); + UpdateActiveState(); base.LoadComplete(); } - private void updateActiveState() + protected virtual void UpdateActiveState() { DarkerColour = Active.Value ? ColourProvider.Highlight1 : ColourProvider.Background3; LighterColour = Active.Value ? ColourProvider.Colour0 : ColourProvider.Background1; diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 144b51d3e8..1b8848f3d5 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -64,8 +64,8 @@ namespace osu.Game.Graphics.UserInterface X = value.HasFlagFast(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; - Remove(c1); - Remove(c2); + Remove(c1, false); + Remove(c2, false); c1.Depth = value.HasFlagFast(Anchor.x2) ? 0 : 1; c2.Depth = value.HasFlagFast(Anchor.x2) ? 1 : 0; Add(c1); 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/IO/IStorageResourceProvider.cs b/osu.Game/IO/IStorageResourceProvider.cs index 1cb6509cac..08982a8b5f 100644 --- a/osu.Game/IO/IStorageResourceProvider.cs +++ b/osu.Game/IO/IStorageResourceProvider.cs @@ -1,9 +1,8 @@ // 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.Audio; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Database; @@ -12,10 +11,15 @@ namespace osu.Game.IO { public interface IStorageResourceProvider { + /// + /// The game renderer. + /// + IRenderer Renderer { get; } + /// /// Retrieve the game-wide audio manager. /// - AudioManager AudioManager { get; } + AudioManager? AudioManager { get; } /// /// Access game-wide user files. diff --git a/osu.Game/IO/LineBufferedReader.cs b/osu.Game/IO/LineBufferedReader.cs index 93e554b43d..da1cdba73b 100644 --- a/osu.Game/IO/LineBufferedReader.cs +++ b/osu.Game/IO/LineBufferedReader.cs @@ -19,7 +19,7 @@ namespace osu.Game.IO public LineBufferedReader(Stream stream, bool leaveOpen = false) { - streamReader = new StreamReader(stream, Encoding.UTF8, true, -1, leaveOpen); + streamReader = new StreamReader(stream, Encoding.UTF8, true, 1024, leaveOpen); } /// diff --git a/osu.Game/IO/MigratableStorage.cs b/osu.Game/IO/MigratableStorage.cs index 30e74adca4..14a3c5a43c 100644 --- a/osu.Game/IO/MigratableStorage.cs +++ b/osu.Game/IO/MigratableStorage.cs @@ -26,6 +26,11 @@ namespace osu.Game.IO /// public virtual string[] IgnoreFiles => Array.Empty(); + /// + /// A list of file/directory suffixes which should not be migrated. + /// + public virtual string[] IgnoreSuffixes => Array.Empty(); + protected MigratableStorage(Storage storage, string subPath = null) : base(storage, subPath) { @@ -73,6 +78,9 @@ namespace osu.Game.IO if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) continue; + if (IgnoreSuffixes.Any(suffix => fi.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + allFilesDeleted &= AttemptOperation(() => fi.Delete(), throwOnFailure: false); } @@ -81,6 +89,9 @@ namespace osu.Game.IO if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) continue; + if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + allFilesDeleted &= AttemptOperation(() => dir.Delete(true), throwOnFailure: false); } @@ -96,12 +107,25 @@ namespace osu.Game.IO if (!destination.Exists) Directory.CreateDirectory(destination.FullName); - foreach (System.IO.FileInfo fi in source.GetFiles()) + foreach (System.IO.FileInfo fileInfo in source.GetFiles()) { - if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + if (topLevelExcludes && IgnoreFiles.Contains(fileInfo.Name)) continue; - AttemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); + if (IgnoreSuffixes.Any(suffix => fileInfo.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + + AttemptOperation(() => + { + fileInfo.Refresh(); + + // A temporary file may have been deleted since the initial GetFiles operation. + // We don't want the whole migration process to fail in such a case. + if (!fileInfo.Exists) + return; + + fileInfo.CopyTo(Path.Combine(destination.FullName, fileInfo.Name), true); + }); } foreach (DirectoryInfo dir in source.GetDirectories()) @@ -109,6 +133,9 @@ namespace osu.Game.IO if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) continue; + if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); } } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 89bdd09f0d..f4c55e4b0e 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -38,15 +38,20 @@ namespace osu.Game.IO public override string[] IgnoreDirectories => new[] { "cache", - $"{OsuGameBase.CLIENT_DATABASE_FILENAME}.management", }; public override string[] IgnoreFiles => new[] { "framework.ini", "storage.ini", - $"{OsuGameBase.CLIENT_DATABASE_FILENAME}.note", - $"{OsuGameBase.CLIENT_DATABASE_FILENAME}.lock", + }; + + public override string[] IgnoreSuffixes => new[] + { + // Realm pipe files don't play well with copy operations + ".note", + ".lock", + ".management", }; public OsuStorage(GameHost host, Storage defaultStorage) @@ -94,6 +99,8 @@ namespace osu.Game.IO error = OsuStorageError.None; Storage lastStorage = UnderlyingStorage; + Logger.Log($"Attempting to use custom storage location {CustomStoragePath}"); + try { Storage userStorage = host.GetStorage(CustomStoragePath); @@ -102,6 +109,7 @@ namespace osu.Game.IO error = OsuStorageError.AccessibleButEmpty; ChangeTargetStorage(userStorage); + Logger.Log($"Storage successfully changed to {CustomStoragePath}."); } catch { @@ -109,6 +117,9 @@ namespace osu.Game.IO ChangeTargetStorage(lastStorage); } + if (error != OsuStorageError.None) + Logger.Log($"Custom storage location could not be used ({error})."); + return error == OsuStorageError.None; } diff --git a/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs b/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs index 4808ac1384..b51a8473ca 100644 --- a/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs +++ b/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs @@ -3,8 +3,8 @@ #nullable disable -using Humanizer; using Newtonsoft.Json.Serialization; +using osu.Game.Extensions; namespace osu.Game.IO.Serialization { @@ -12,7 +12,7 @@ namespace osu.Game.IO.Serialization { protected override string ResolvePropertyName(string propertyName) { - return propertyName.Underscore(); + return propertyName.ToSnakeCase(); } } } diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index 14a041b459..4f079ab435 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -55,13 +55,13 @@ namespace osu.Game.Input.Bindings { // The first fire of this is a bit redundant as this is being called in base.LoadComplete, // but this is safest in case the subscription is restored after a context recycle. - reloadMappings(sender.AsQueryable()); + ReloadMappings(sender.AsQueryable()); }); base.LoadComplete(); } - protected override void ReloadMappings() => reloadMappings(queryRealmKeyBindings(realm.Realm)); + protected sealed override void ReloadMappings() => ReloadMappings(queryRealmKeyBindings(realm.Realm)); private IQueryable queryRealmKeyBindings(Realm realm) { @@ -70,7 +70,7 @@ namespace osu.Game.Input.Bindings .Where(b => b.RulesetName == rulesetName && b.Variant == variant); } - private void reloadMappings(IQueryable realmKeyBindings) + protected virtual void ReloadMappings(IQueryable realmKeyBindings) { var defaults = DefaultKeyBindings.ToList(); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 21b49286ea..ad53f6d90f 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.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 osu.Framework.Graphics; @@ -15,10 +13,11 @@ namespace osu.Game.Input.Bindings { public class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput { - private readonly Drawable handler; - private InputManager parentInputManager; + private readonly Drawable? handler; - public GlobalActionContainer(OsuGameBase game) + private InputManager? parentInputManager; + + public GlobalActionContainer(OsuGameBase? game) : base(matchingMode: KeyCombinationMatchingMode.Modifiers) { if (game is IKeyBindingHandler) @@ -32,7 +31,10 @@ namespace osu.Game.Input.Bindings parentInputManager = GetContainingInputManager(); } + // IMPORTANT: Do not change the order of key bindings in this list. + // It is used to decide the order of precedence (see note in DatabasedKeyBindingContainer). public override IEnumerable DefaultKeyBindings => GlobalKeyBindings + .Concat(OverlayKeyBindings) .Concat(EditorKeyBindings) .Concat(InGameKeyBindings) .Concat(SongSelectKeyBindings) @@ -40,24 +42,6 @@ namespace osu.Game.Input.Bindings public IEnumerable GlobalKeyBindings => new[] { - new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying), - new KeyBinding(InputKey.F8, GlobalAction.ToggleChat), - new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial), - new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons), - new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot), - - new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), - new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), - new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), - new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing), - new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), - - new KeyBinding(InputKey.Escape, GlobalAction.Back), - new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), - - new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), - new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious), new KeyBinding(InputKey.Down, GlobalAction.SelectNext), @@ -68,7 +52,32 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.Enter, GlobalAction.Select), new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), + new KeyBinding(InputKey.Escape, GlobalAction.Back), + new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), + + new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), + + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay), + new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), + new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.ToggleProfile), + + new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.R }, GlobalAction.RandomSkin), + + new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons), + new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot), + }; + + public IEnumerable OverlayKeyBindings => new[] + { + new KeyBinding(InputKey.F8, GlobalAction.ToggleChat), + new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying), + new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial), + new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing), + new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), + new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), }; public IEnumerable EditorKeyBindings => new[] @@ -328,5 +337,11 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTapForBPM))] EditorTapForBPM, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleFPSCounter))] + ToggleFPSDisplay, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleProfile))] + ToggleProfile, } } diff --git a/osu.Game/Input/OsuConfineMouseMode.cs b/osu.Game/Input/OsuConfineMouseMode.cs index 2a93ac1cd6..2d914ac6e0 100644 --- a/osu.Game/Input/OsuConfineMouseMode.cs +++ b/osu.Game/Input/OsuConfineMouseMode.cs @@ -3,8 +3,9 @@ #nullable disable -using System.ComponentModel; using osu.Framework.Input; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Input { @@ -17,18 +18,20 @@ namespace osu.Game.Input /// /// The mouse cursor will be free to move outside the game window. /// + [LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.NeverConfine))] Never, /// /// The mouse cursor will be locked to the window bounds during gameplay, /// but may otherwise move freely. /// - [Description("During Gameplay")] + [LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.ConfineDuringGameplay))] DuringGameplay, /// /// The mouse cursor will always be locked to the window bounds while the game has focus. /// + [LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.AlwaysConfine))] Always } } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 1ee562e122..385ebd0593 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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; @@ -89,6 +89,21 @@ namespace osu.Game.Localisation /// public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Collections"); + /// + /// "Mod presets" + /// + public static LocalisableString ModPresets => new TranslatableString(getKey(@"mod_presets"), @"Mod presets"); + + /// + /// "Name" + /// + public static LocalisableString Name => new TranslatableString(getKey(@"name"), @"Name"); + + /// + /// "Description" + /// + public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"Description"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs index 74b2c8d892..dd21739096 100644 --- a/osu.Game/Localisation/DebugSettingsStrings.cs +++ b/osu.Game/Localisation/DebugSettingsStrings.cs @@ -44,11 +44,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ClearAllCaches => new TranslatableString(getKey(@"clear_all_caches"), @"Clear all caches"); - /// - /// "Compact realm" - /// - public static LocalisableString CompactRealm => new TranslatableString(getKey(@"compact_realm"), @"Compact realm"); - private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs b/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs new file mode 100644 index 0000000000..33738fe95e --- /dev/null +++ b/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs @@ -0,0 +1,29 @@ +// 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 DeleteConfirmationDialogStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DeleteConfirmationDialog"; + + /// + /// "Confirm deletion of" + /// + public static LocalisableString HeaderText => new TranslatableString(getKey(@"header_text"), @"Confirm deletion of"); + + /// + /// "Yes. Go for it." + /// + public static LocalisableString Confirm => new TranslatableString(getKey(@"confirm"), @"Yes. Go for it."); + + /// + /// "No! Abort mission" + /// + public static LocalisableString Cancel => new TranslatableString(getKey(@"cancel"), @"No! Abort mission"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} 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/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 8a0f773551..40f39d927d 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -79,6 +79,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AlwaysShowKeyOverlay => new TranslatableString(getKey(@"key_overlay"), @"Always show key overlay"); + /// + /// "Always show gameplay leaderboard" + /// + public static LocalisableString AlwaysShowGameplayLeaderboard => new TranslatableString(getKey(@"gameplay_leaderboard"), @"Always show gameplay leaderboard"); + /// /// "Always play first combo break sound" /// @@ -104,6 +109,31 @@ namespace osu.Game.Localisation /// public static LocalisableString IncreaseFirstObjectVisibility => new TranslatableString(getKey(@"increase_first_object_visibility"), @"Increase visibility of first object when visual impairment mods are enabled"); + /// + /// "Hide during gameplay" + /// + public static LocalisableString HideDuringGameplay => new TranslatableString(getKey(@"hide_during_gameplay"), @"Hide during gameplay"); + + /// + /// "Always" + /// + public static LocalisableString AlwaysShowHUD => new TranslatableString(getKey(@"always_show_hud"), @"Always"); + + /// + /// "Never" + /// + public static LocalisableString NeverShowHUD => new TranslatableString(getKey(@"never_show_hud"), @"Never"); + + /// + /// "Standardised" + /// + public static LocalisableString StandardisedScoreDisplay => new TranslatableString(getKey(@"standardised_score_display"), @"Standardised"); + + /// + /// "Classic" + /// + public static LocalisableString ClassicScoreDisplay => new TranslatableString(getKey(@"classic_score_display"), @"Classic"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 2aa91f5245..3278b20983 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -64,6 +64,11 @@ namespace osu.Game.Localisation /// public static LocalisableString RunSetupWizard => new TranslatableString(getKey(@"run_setup_wizard"), @"Run setup wizard"); + /// + /// "You are running the latest release ({0})" + /// + public static LocalisableString RunningLatestRelease(string version) => new TranslatableString(getKey(@"running_latest_release"), @"You are running the latest release ({0})", version); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 82d03dbb5b..172818c1c0 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -149,6 +149,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ToggleNotifications => new TranslatableString(getKey(@"toggle_notifications"), @"Toggle notifications"); + /// + /// "Toggle profile" + /// + public static LocalisableString ToggleProfile => new TranslatableString(getKey(@"toggle_profile"), @"Toggle profile"); + /// /// "Pause gameplay" /// @@ -274,6 +279,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ToggleSkinEditor => new TranslatableString(getKey(@"toggle_skin_editor"), @"Toggle skin editor"); + /// + /// "Toggle FPS counter" + /// + public static LocalisableString ToggleFPSCounter => new TranslatableString(getKey(@"toggle_fps_counter"), @"Toggle FPS counter"); + /// /// "Previous volume meter" /// diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 38355d9041..2ab9d9de87 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -129,6 +129,16 @@ namespace osu.Game.Localisation /// public static LocalisableString UseHardwareAcceleration => new TranslatableString(getKey(@"use_hardware_acceleration"), @"Use hardware acceleration"); + /// + /// "JPG (web-friendly)" + /// + public static LocalisableString Jpg => new TranslatableString(getKey(@"jpg_web_friendly"), @"JPG (web-friendly)"); + + /// + /// "PNG (lossless)" + /// + public static LocalisableString Png => new TranslatableString(getKey(@"png_lossless"), @"PNG (lossless)"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/InputSettingsStrings.cs b/osu.Game/Localisation/InputSettingsStrings.cs index e46b4cecf3..a0da6cc2f7 100644 --- a/osu.Game/Localisation/InputSettingsStrings.cs +++ b/osu.Game/Localisation/InputSettingsStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString GlobalKeyBindingHeader => new TranslatableString(getKey(@"global_key_binding_header"), @"Global"); + /// + /// "Overlays" + /// + public static LocalisableString OverlaysSection => new TranslatableString(getKey(@"overlays_section"), @"Overlays"); + /// /// "Song Select" /// diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index c13a1a10cb..6a4e5110e6 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using JetBrains.Annotations; namespace osu.Game.Localisation { + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public enum Language { [Description(@"English")] diff --git a/osu.Game/Localisation/LayoutSettingsStrings.cs b/osu.Game/Localisation/LayoutSettingsStrings.cs index b4326b8e39..9b8b207c47 100644 --- a/osu.Game/Localisation/LayoutSettingsStrings.cs +++ b/osu.Game/Localisation/LayoutSettingsStrings.cs @@ -29,6 +29,26 @@ namespace osu.Game.Localisation /// public static LocalisableString FullscreenMacOSNote => new TranslatableString(getKey(@"fullscreen_macos_note"), @"Using fullscreen on macOS makes interacting with the menu bar and spaces no longer work, and may lead to freezes if a system dialog is presented. Using borderless is recommended."); + /// + /// "Excluding overlays" + /// + public static LocalisableString ScaleEverythingExcludingOverlays => new TranslatableString(getKey(@"scale_everything_excluding_overlays"), @"Excluding overlays"); + + /// + /// "Everything" + /// + public static LocalisableString ScaleEverything => new TranslatableString(getKey(@"scale_everything"), @"Everything"); + + /// + /// "Gameplay" + /// + public static LocalisableString ScaleGameplay => new TranslatableString(getKey(@"scale_gameplay"), @"Gameplay"); + + /// + /// "Off" + /// + public static LocalisableString ScalingOff => new TranslatableString(getKey(@"scaling_off"), @"Off"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs index 7a04bcd1ca..8aa0adf7a0 100644 --- a/osu.Game/Localisation/MaintenanceSettingsStrings.cs +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -19,6 +19,41 @@ namespace osu.Game.Localisation /// public static LocalisableString SelectDirectory => new TranslatableString(getKey(@"select_directory"), @"Select directory"); + /// + /// "Migration in progress" + /// + public static LocalisableString MigrationInProgress => new TranslatableString(getKey(@"migration_in_progress"), @"Migration in progress"); + + /// + /// "This could take a few minutes depending on the speed of your disk(s)." + /// + public static LocalisableString MigrationDescription => new TranslatableString(getKey(@"migration_description"), @"This could take a few minutes depending on the speed of your disk(s)."); + + /// + /// "Please avoid interacting with the game!" + /// + public static LocalisableString ProhibitedInteractDuringMigration => new TranslatableString(getKey(@"prohibited_interact_during_migration"), @"Please avoid interacting with the game!"); + + /// + /// "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up." + /// + public static LocalisableString FailedCleanupNotification => new TranslatableString(getKey(@"failed_cleanup_notification"), @"Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up."); + + /// + /// "Please select a new location" + /// + public static LocalisableString SelectNewLocation => new TranslatableString(getKey(@"select_new_location"), @"Please select a new location"); + + /// + /// "The target directory already seems to have an osu! install. Use that data instead?" + /// + public static LocalisableString TargetDirectoryAlreadyInstalledOsu => new TranslatableString(getKey(@"target_directory_already_installed_osu"), @"The target directory already seems to have an osu! install. Use that data instead?"); + + /// + /// "To complete this operation, osu! will close. Please open it again to use the new data location." + /// + public static LocalisableString RestartAndReOpenRequiredForCompletion => new TranslatableString(getKey(@"restart_and_re_open_required_for_completion"), @"To complete this operation, osu! will close. Please open it again to use the new data location."); + /// /// "Import beatmaps from stable" /// @@ -74,6 +109,36 @@ namespace osu.Game.Localisation /// public static LocalisableString RestoreAllRecentlyDeletedBeatmaps => new TranslatableString(getKey(@"restore_all_recently_deleted_beatmaps"), @"Restore all recently deleted beatmaps"); + /// + /// "Delete ALL mod presets" + /// + public static LocalisableString DeleteAllModPresets => new TranslatableString(getKey(@"delete_all_mod_presets"), @"Delete ALL mod presets"); + + /// + /// "Restore all recently deleted mod presets" + /// + public static LocalisableString RestoreAllRecentlyDeletedModPresets => new TranslatableString(getKey(@"restore_all_recently_deleted_mod_presets"), @"Restore all recently deleted mod presets"); + + /// + /// "Deleted all collections!" + /// + public static LocalisableString DeletedAllCollections => new TranslatableString(getKey(@"deleted_all_collections"), @"Deleted all collections!"); + + /// + /// "Deleted all mod presets!" + /// + public static LocalisableString DeletedAllModPresets => new TranslatableString(getKey(@"deleted_all_mod_presets"), @"Deleted all mod presets!"); + + /// + /// "Restored all deleted mod presets!" + /// + public static LocalisableString RestoredAllDeletedModPresets => new TranslatableString(getKey(@"restored_all_deleted_mod_presets"), @"Restored all deleted mod presets!"); + + /// + /// "Please select your osu!stable install location" + /// + public static LocalisableString StableDirectorySelectHeader => new TranslatableString(getKey(@"stable_directory_select_header"), @"Please select your osu!stable install location"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index e9af7147e3..d6a01c4794 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -24,6 +24,16 @@ namespace osu.Game.Localisation /// public static LocalisableString ModCustomisation => new TranslatableString(getKey(@"mod_customisation"), @"Mod Customisation"); + /// + /// "Personal Presets" + /// + public static LocalisableString PersonalPresets => new TranslatableString(getKey(@"personal_presets"), @"Personal Presets"); + + /// + /// "Add preset" + /// + public static LocalisableString AddPreset => new TranslatableString(getKey(@"add_preset"), @"Add preset"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs index fd7225ad2e..1772f03b29 100644 --- a/osu.Game/Localisation/MouseSettingsStrings.cs +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -64,6 +64,21 @@ namespace osu.Game.Localisation /// public static LocalisableString HighPrecisionPlatformWarning => new TranslatableString(getKey(@"high_precision_platform_warning"), @"This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now."); + /// + /// "Always" + /// + public static LocalisableString AlwaysConfine => new TranslatableString(getKey(@"always_confine"), @"Always"); + + /// + /// "During Gameplay" + /// + public static LocalisableString ConfineDuringGameplay => new TranslatableString(getKey(@"confine_during_gameplay"), @"During Gameplay"); + + /// + /// "Never" + /// + public static LocalisableString NeverConfine => new TranslatableString(getKey(@"never_confine"), @"Never"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 6862f4ac2c..3200b1c75c 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -64,6 +64,21 @@ namespace osu.Game.Localisation /// public static LocalisableString ShowExplicitContent => new TranslatableString(getKey(@"show_explicit_content"), @"Show explicit content in search results"); + /// + /// "Hide identifiable information" + /// + public static LocalisableString HideIdentifiableInformation => new TranslatableString(getKey(@"hide_identifiable_information"), @"Hide identifiable information"); + + /// + /// "Full" + /// + public static LocalisableString DiscordPresenceFull => new TranslatableString(getKey(@"discord_presence_full"), @"Full"); + + /// + /// "Off" + /// + public static LocalisableString DiscordPresenceOff => new TranslatableString(getKey(@"discord_presence_off"), @"Off"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/PopupDialogStrings.cs b/osu.Game/Localisation/PopupDialogStrings.cs new file mode 100644 index 0000000000..b2e9673cbe --- /dev/null +++ b/osu.Game/Localisation/PopupDialogStrings.cs @@ -0,0 +1,24 @@ +// 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 PopupDialogStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.PopupDialog"; + + /// + /// "Are you sure you want to update this beatmap?" + /// + public static LocalisableString UpdateLocallyModifiedText => new TranslatableString(getKey(@"update_locally_modified_text"), @"Are you sure you want to update this beatmap?"); + + /// + /// "This will discard all local changes you have on that beatmap." + /// + public static LocalisableString UpdateLocallyModifiedDescription => new TranslatableString(getKey(@"update_locally_modified_description"), @"This will discard all local changes you have on that beatmap."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs index a356c9e20b..bc4be56c80 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -14,6 +14,21 @@ namespace osu.Game.Localisation /// public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets"); + /// + /// "None" + /// + public static LocalisableString BorderNone => new TranslatableString(getKey(@"no_borders"), @"None"); + + /// + /// "Corners" + /// + public static LocalisableString BorderCorners => new TranslatableString(getKey(@"corner_borders"), @"Corners"); + + /// + /// "Full" + /// + public static LocalisableString BorderFull => new TranslatableString(getKey(@"full_borders"), @"Full"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index 81035c5a5e..4b6b0ce1d6 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -34,6 +34,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AutoCursorSize => new TranslatableString(getKey(@"auto_cursor_size"), @"Adjust gameplay cursor size based on current beatmap"); + /// + /// "Show gameplay cursor during touch input" + /// + public static LocalisableString GameplayCursorDuringTouch => new TranslatableString(getKey(@"gameplay_cursor_during_touch"), @"Show gameplay cursor during touch input"); + /// /// "Beatmap skins" /// diff --git a/osu.Game/Localisation/SongSelectStrings.cs b/osu.Game/Localisation/SongSelectStrings.cs new file mode 100644 index 0000000000..12f70cd967 --- /dev/null +++ b/osu.Game/Localisation/SongSelectStrings.cs @@ -0,0 +1,24 @@ +// 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 SongSelectStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SongSelect"; + + /// + /// "Local" + /// + public static LocalisableString LocallyModified => new TranslatableString(getKey(@"locally_modified"), @"Local"); + + /// + /// "Has been locally modified" + /// + public static LocalisableString LocallyModifiedTooltip => new TranslatableString(getKey(@"locally_modified_tooltip"), @"Has been locally modified"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs index d62d348df9..6c2e3c1f9c 100644 --- a/osu.Game/Localisation/TabletSettingsStrings.cs +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString NoTabletDetected => new TranslatableString(getKey(@"no_tablet_detected"), @"No tablet detected!"); + /// + /// "If your tablet is not detected, please read [this FAQ]({0}) for troubleshooting steps." + /// + public static LocalisableString NoTabletDetectedDescription(string url) => new TranslatableString(getKey(@"no_tablet_detected_description"), @"If your tablet is not detected, please read [this FAQ]({0}) for troubleshooting steps.", url); + /// /// "Reset to full area" /// diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs index 52e75425bf..da798a3937 100644 --- a/osu.Game/Localisation/ToastStrings.cs +++ b/osu.Game/Localisation/ToastStrings.cs @@ -34,6 +34,21 @@ namespace osu.Game.Localisation /// public static LocalisableString RestartTrack => new TranslatableString(getKey(@"restart_track"), @"Restart track"); + /// + /// "Beatmap saved" + /// + public static LocalisableString BeatmapSaved => new TranslatableString(getKey(@"beatmap_saved"), @"Beatmap saved"); + + /// + /// "Skin saved" + /// + public static LocalisableString SkinSaved => new TranslatableString(getKey(@"skin_saved"), @"Skin saved"); + + /// + /// "URL copied" + /// + public static LocalisableString UrlCopied => new TranslatableString(getKey(@"url_copied"), @"URL copied"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ToolbarStrings.cs b/osu.Game/Localisation/ToolbarStrings.cs new file mode 100644 index 0000000000..6dc8a1e50c --- /dev/null +++ b/osu.Game/Localisation/ToolbarStrings.cs @@ -0,0 +1,24 @@ +// 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 ToolbarStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Toolbar"; + + /// + /// "Connection interrupted, will try to reconnect..." + /// + public static LocalisableString AttemptingToReconnect => new TranslatableString(getKey(@"attempting_to_reconnect"), @"Connection interrupted, will try to reconnect..."); + + /// + /// "Connecting..." + /// + public static LocalisableString Connecting => new TranslatableString(getKey(@"connecting"), @"Connecting..."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index a090b8c14c..ea664d7b50 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -114,6 +114,46 @@ namespace osu.Game.Localisation /// public static LocalisableString NoLimit => new TranslatableString(getKey(@"no_limit"), @"no limit"); + /// + /// "Beatmap (with storyboard / video)" + /// + public static LocalisableString BeatmapWithStoryboard => new TranslatableString(getKey(@"beatmap_with_storyboard"), @"Beatmap (with storyboard / video)"); + + /// + /// "Always" + /// + public static LocalisableString AlwaysSeasonalBackground => new TranslatableString(getKey(@"always_seasonal_backgrounds"), @"Always"); + + /// + /// "Never" + /// + public static LocalisableString NeverSeasonalBackground => new TranslatableString(getKey(@"never_seasonal_backgrounds"), @"Never"); + + /// + /// "Sometimes" + /// + public static LocalisableString SometimesSeasonalBackground => new TranslatableString(getKey(@"sometimes_seasonal_backgrounds"), @"Sometimes"); + + /// + /// "Sequential" + /// + public static LocalisableString SequentialHotkeyStyle => new TranslatableString(getKey(@"mods_sequential_hotkeys"), @"Sequential"); + + /// + /// "Classic" + /// + public static LocalisableString ClassicHotkeyStyle => new TranslatableString(getKey(@"mods_classic_hotkeys"), @"Classic"); + + /// + /// "Never repeat" + /// + public static LocalisableString NeverRepeat => new TranslatableString(getKey(@"never_repeat_random"), @"Never repeat"); + + /// + /// "True Random" + /// + public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Migrations/20171019041408_InitialCreate.Designer.cs b/osu.Game/Migrations/20171019041408_InitialCreate.Designer.cs deleted file mode 100644 index c751530bf4..0000000000 --- a/osu.Game/Migrations/20171019041408_InitialCreate.Designer.cs +++ /dev/null @@ -1,293 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171019041408_InitialCreate")] - partial class InitialCreate - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash"); - - b.HasIndex("MetadataID"); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171019041408_InitialCreate.cs b/osu.Game/Migrations/20171019041408_InitialCreate.cs deleted file mode 100644 index 08ab64fd08..0000000000 --- a/osu.Game/Migrations/20171019041408_InitialCreate.cs +++ /dev/null @@ -1,314 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class InitialCreate : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "BeatmapDifficulty", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - ApproachRate = table.Column(type: "REAL", nullable: false), - CircleSize = table.Column(type: "REAL", nullable: false), - DrainRate = table.Column(type: "REAL", nullable: false), - OverallDifficulty = table.Column(type: "REAL", nullable: false), - SliderMultiplier = table.Column(type: "REAL", nullable: false), - SliderTickRate = table.Column(type: "REAL", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapDifficulty", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "BeatmapMetadata", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Artist = table.Column(type: "TEXT", nullable: true), - ArtistUnicode = table.Column(type: "TEXT", nullable: true), - AudioFile = table.Column(type: "TEXT", nullable: true), - Author = table.Column(type: "TEXT", nullable: true), - BackgroundFile = table.Column(type: "TEXT", nullable: true), - PreviewTime = table.Column(type: "INTEGER", nullable: false), - Source = table.Column(type: "TEXT", nullable: true), - Tags = table.Column(type: "TEXT", nullable: true), - Title = table.Column(type: "TEXT", nullable: true), - TitleUnicode = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapMetadata", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "FileInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Hash = table.Column(type: "TEXT", nullable: true), - ReferenceCount = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_FileInfo", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "KeyBinding", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Action = table.Column(type: "INTEGER", nullable: false), - Keys = table.Column(type: "TEXT", nullable: true), - RulesetID = table.Column(type: "INTEGER", nullable: true), - Variant = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_KeyBinding", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "RulesetInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Available = table.Column(type: "INTEGER", nullable: false), - InstantiationInfo = table.Column(type: "TEXT", nullable: true), - Name = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RulesetInfo", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "BeatmapSetInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - DeletePending = table.Column(type: "INTEGER", nullable: false), - Hash = table.Column(type: "TEXT", nullable: true), - MetadataID = table.Column(type: "INTEGER", nullable: true), - OnlineBeatmapSetID = table.Column(type: "INTEGER", nullable: true), - Protected = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapSetInfo", x => x.ID); - table.ForeignKey( - name: "FK_BeatmapSetInfo_BeatmapMetadata_MetadataID", - column: x => x.MetadataID, - principalTable: "BeatmapMetadata", - principalColumn: "ID", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "BeatmapInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - AudioLeadIn = table.Column(type: "INTEGER", nullable: false), - BaseDifficultyID = table.Column(type: "INTEGER", nullable: false), - BeatDivisor = table.Column(type: "INTEGER", nullable: false), - BeatmapSetInfoID = table.Column(type: "INTEGER", nullable: false), - Countdown = table.Column(type: "INTEGER", nullable: false), - DistanceSpacing = table.Column(type: "REAL", nullable: false), - GridSize = table.Column(type: "INTEGER", nullable: false), - Hash = table.Column(type: "TEXT", nullable: true), - Hidden = table.Column(type: "INTEGER", nullable: false), - LetterboxInBreaks = table.Column(type: "INTEGER", nullable: false), - MD5Hash = table.Column(type: "TEXT", nullable: true), - MetadataID = table.Column(type: "INTEGER", nullable: true), - OnlineBeatmapID = table.Column(type: "INTEGER", nullable: true), - Path = table.Column(type: "TEXT", nullable: true), - RulesetID = table.Column(type: "INTEGER", nullable: false), - SpecialStyle = table.Column(type: "INTEGER", nullable: false), - StackLeniency = table.Column(type: "REAL", nullable: false), - StarDifficulty = table.Column(type: "REAL", nullable: false), - StoredBookmarks = table.Column(type: "TEXT", nullable: true), - TimelineZoom = table.Column(type: "REAL", nullable: false), - Version = table.Column(type: "TEXT", nullable: true), - WidescreenStoryboard = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapInfo", x => x.ID); - table.ForeignKey( - name: "FK_BeatmapInfo_BeatmapDifficulty_BaseDifficultyID", - column: x => x.BaseDifficultyID, - principalTable: "BeatmapDifficulty", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_BeatmapInfo_BeatmapSetInfo_BeatmapSetInfoID", - column: x => x.BeatmapSetInfoID, - principalTable: "BeatmapSetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_BeatmapInfo_BeatmapMetadata_MetadataID", - column: x => x.MetadataID, - principalTable: "BeatmapMetadata", - principalColumn: "ID", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_BeatmapInfo_RulesetInfo_RulesetID", - column: x => x.RulesetID, - principalTable: "RulesetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "BeatmapSetFileInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - BeatmapSetInfoID = table.Column(type: "INTEGER", nullable: false), - FileInfoID = table.Column(type: "INTEGER", nullable: false), - Filename = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapSetFileInfo", x => x.ID); - table.ForeignKey( - name: "FK_BeatmapSetFileInfo_BeatmapSetInfo_BeatmapSetInfoID", - column: x => x.BeatmapSetInfoID, - principalTable: "BeatmapSetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_BeatmapSetFileInfo_FileInfo_FileInfoID", - column: x => x.FileInfoID, - principalTable: "FileInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_BaseDifficultyID", - table: "BeatmapInfo", - column: "BaseDifficultyID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_BeatmapSetInfoID", - table: "BeatmapInfo", - column: "BeatmapSetInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MetadataID", - table: "BeatmapInfo", - column: "MetadataID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_RulesetID", - table: "BeatmapInfo", - column: "RulesetID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetFileInfo_BeatmapSetInfoID", - table: "BeatmapSetFileInfo", - column: "BeatmapSetInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetFileInfo_FileInfoID", - table: "BeatmapSetFileInfo", - column: "FileInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_DeletePending", - table: "BeatmapSetInfo", - column: "DeletePending"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_MetadataID", - table: "BeatmapSetInfo", - column: "MetadataID"); - - migrationBuilder.CreateIndex( - name: "IX_FileInfo_Hash", - table: "FileInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_FileInfo_ReferenceCount", - table: "FileInfo", - column: "ReferenceCount"); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_Action", - table: "KeyBinding", - column: "Action"); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_Variant", - table: "KeyBinding", - column: "Variant"); - - migrationBuilder.CreateIndex( - name: "IX_RulesetInfo_Available", - table: "RulesetInfo", - column: "Available"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "BeatmapInfo"); - - migrationBuilder.DropTable( - name: "BeatmapSetFileInfo"); - - migrationBuilder.DropTable( - name: "KeyBinding"); - - migrationBuilder.DropTable( - name: "BeatmapDifficulty"); - - migrationBuilder.DropTable( - name: "RulesetInfo"); - - migrationBuilder.DropTable( - name: "BeatmapSetInfo"); - - migrationBuilder.DropTable( - name: "FileInfo"); - - migrationBuilder.DropTable( - name: "BeatmapMetadata"); - } - } -} diff --git a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.Designer.cs b/osu.Game/Migrations/20171025071459_AddMissingIndexRules.Designer.cs deleted file mode 100644 index 4cd234f2ef..0000000000 --- a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.Designer.cs +++ /dev/null @@ -1,299 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171025071459_AddMissingIndexRules")] - partial class AddMissingIndexRules - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.cs b/osu.Game/Migrations/20171025071459_AddMissingIndexRules.cs deleted file mode 100644 index 4ec3952941..0000000000 --- a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.cs +++ /dev/null @@ -1,83 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddMissingIndexRules : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_OnlineBeatmapSetID", - table: "BeatmapSetInfo", - column: "OnlineBeatmapSetID", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapSetInfo_OnlineBeatmapSetID", - table: "BeatmapSetInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash"); - } - } -} diff --git a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.Designer.cs b/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.Designer.cs deleted file mode 100644 index 006acf12cd..0000000000 --- a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.Designer.cs +++ /dev/null @@ -1,302 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171119065731_AddBeatmapOnlineIDUniqueConstraint")] - partial class AddBeatmapOnlineIDUniqueConstraint - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs b/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs deleted file mode 100644 index 6aba12f86f..0000000000 --- a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs +++ /dev/null @@ -1,26 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddBeatmapOnlineIDUniqueConstraint : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_OnlineBeatmapID", - table: "BeatmapInfo", - column: "OnlineBeatmapID", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_OnlineBeatmapID", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.Designer.cs b/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.Designer.cs deleted file mode 100644 index fc2496bc24..0000000000 --- a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.Designer.cs +++ /dev/null @@ -1,307 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171209034410_AddRulesetInfoShortName")] - partial class AddRulesetInfoShortName - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.cs b/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.cs deleted file mode 100644 index 5688455f79..0000000000 --- a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.cs +++ /dev/null @@ -1,36 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddRulesetInfoShortName : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ShortName", - table: "RulesetInfo", - type: "TEXT", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_RulesetInfo_ShortName", - table: "RulesetInfo", - column: "ShortName", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_RulesetInfo_ShortName", - table: "RulesetInfo"); - - migrationBuilder.DropColumn( - name: "ShortName", - table: "RulesetInfo"); - } - } -} diff --git a/osu.Game/Migrations/20180125143340_Settings.Designer.cs b/osu.Game/Migrations/20180125143340_Settings.Designer.cs deleted file mode 100644 index 4bb599eec1..0000000000 --- a/osu.Game/Migrations/20180125143340_Settings.Designer.cs +++ /dev/null @@ -1,329 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180125143340_Settings")] - partial class Settings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180125143340_Settings.cs b/osu.Game/Migrations/20180125143340_Settings.cs deleted file mode 100644 index 1feb37531f..0000000000 --- a/osu.Game/Migrations/20180125143340_Settings.cs +++ /dev/null @@ -1,58 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class Settings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_KeyBinding_Variant", - table: "KeyBinding"); - - migrationBuilder.CreateTable( - name: "Settings", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Key = table.Column(type: "TEXT", nullable: false), - RulesetID = table.Column(type: "INTEGER", nullable: true), - Value = table.Column(type: "TEXT", nullable: true), - Variant = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Settings", x => x.ID); - }); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_RulesetID_Variant", - table: "KeyBinding", - columns: new[] { "RulesetID", "Variant" }); - - migrationBuilder.CreateIndex( - name: "IX_Settings_RulesetID_Variant", - table: "Settings", - columns: new[] { "RulesetID", "Variant" }); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Settings"); - - migrationBuilder.DropIndex( - name: "IX_KeyBinding_RulesetID_Variant", - table: "KeyBinding"); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_Variant", - table: "KeyBinding", - column: "Variant"); - } - } -} diff --git a/osu.Game/Migrations/20180131154205_AddMuteBinding.cs b/osu.Game/Migrations/20180131154205_AddMuteBinding.cs deleted file mode 100644 index 8646d1d76b..0000000000 --- a/osu.Game/Migrations/20180131154205_AddMuteBinding.cs +++ /dev/null @@ -1,26 +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 Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; -using osu.Game.Database; -using osu.Game.Input.Bindings; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180131154205_AddMuteBinding")] - public partial class AddMuteBinding : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql($"UPDATE KeyBinding SET Action = Action + 1 WHERE RulesetID IS NULL AND Variant IS NULL AND Action >= {(int)GlobalAction.ToggleMute}"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql($"DELETE FROM KeyBinding WHERE RulesetID IS NULL AND Variant IS NULL AND Action = {(int)GlobalAction.ToggleMute}"); - migrationBuilder.Sql($"UPDATE KeyBinding SET Action = Action - 1 WHERE RulesetID IS NULL AND Variant IS NULL AND Action > {(int)GlobalAction.ToggleMute}"); - } - } -} diff --git a/osu.Game/Migrations/20180219060912_AddSkins.Designer.cs b/osu.Game/Migrations/20180219060912_AddSkins.Designer.cs deleted file mode 100644 index cdc4ef2e66..0000000000 --- a/osu.Game/Migrations/20180219060912_AddSkins.Designer.cs +++ /dev/null @@ -1,379 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180219060912_AddSkins")] - partial class AddSkins - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180219060912_AddSkins.cs b/osu.Game/Migrations/20180219060912_AddSkins.cs deleted file mode 100644 index 319748bed6..0000000000 --- a/osu.Game/Migrations/20180219060912_AddSkins.cs +++ /dev/null @@ -1,74 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSkins : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "SkinInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Creator = table.Column(type: "TEXT", nullable: true), - DeletePending = table.Column(type: "INTEGER", nullable: false), - Name = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_SkinInfo", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "SkinFileInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - FileInfoID = table.Column(type: "INTEGER", nullable: false), - Filename = table.Column(type: "TEXT", nullable: false), - SkinInfoID = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SkinFileInfo", x => x.ID); - table.ForeignKey( - name: "FK_SkinFileInfo_FileInfo_FileInfoID", - column: x => x.FileInfoID, - principalTable: "FileInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_SkinFileInfo_SkinInfo_SkinInfoID", - column: x => x.SkinInfoID, - principalTable: "SkinInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_SkinFileInfo_FileInfoID", - table: "SkinFileInfo", - column: "FileInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_SkinFileInfo_SkinInfoID", - table: "SkinFileInfo", - column: "SkinInfoID"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "SkinFileInfo"); - - migrationBuilder.DropTable( - name: "SkinInfo"); - } - } -} diff --git a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.Designer.cs b/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.Designer.cs deleted file mode 100644 index f28408bfb3..0000000000 --- a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.Designer.cs +++ /dev/null @@ -1,377 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180529055154_RemoveUniqueHashConstraints")] - partial class RemoveUniqueHashConstraints - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.3-rtm-10026"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.cs b/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.cs deleted file mode 100644 index 91eabe8868..0000000000 --- a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.cs +++ /dev/null @@ -1,54 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RemoveUniqueHashConstraints : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash", - unique: true); - } - } -} diff --git a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs deleted file mode 100644 index aaa11e88b6..0000000000 --- a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs +++ /dev/null @@ -1,376 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180621044111_UpdateTaikoDefaultBindings")] - partial class UpdateTaikoDefaultBindings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs deleted file mode 100644 index d888ccd5a2..0000000000 --- a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs +++ /dev/null @@ -1,20 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class UpdateTaikoDefaultBindings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM KeyBinding WHERE RulesetID = 1"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - // we can't really tell if these should be restored or not, so let's just not do so. - } - } -} diff --git a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.Designer.cs b/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.Designer.cs deleted file mode 100644 index 7eeacd56d7..0000000000 --- a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.Designer.cs +++ /dev/null @@ -1,376 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180628011956_RemoveNegativeSetIDs")] - partial class RemoveNegativeSetIDs - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.cs b/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.cs deleted file mode 100644 index fdea636ac6..0000000000 --- a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.cs +++ /dev/null @@ -1,21 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RemoveNegativeSetIDs : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - // There was a change that beatmaps were being loaded with "-1" online IDs, which is completely incorrect. - // This ensures there will not be unique key conflicts as a result of these incorrectly imported beatmaps. - migrationBuilder.Sql("UPDATE BeatmapSetInfo SET OnlineBeatmapSetID = null WHERE OnlineBeatmapSetID <= 0"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20180913080842_AddRankStatus.Designer.cs b/osu.Game/Migrations/20180913080842_AddRankStatus.Designer.cs deleted file mode 100644 index 5ab43da046..0000000000 --- a/osu.Game/Migrations/20180913080842_AddRankStatus.Designer.cs +++ /dev/null @@ -1,380 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180913080842_AddRankStatus")] - partial class AddRankStatus - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.2-rtm-30932"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180913080842_AddRankStatus.cs b/osu.Game/Migrations/20180913080842_AddRankStatus.cs deleted file mode 100644 index bb147dff84..0000000000 --- a/osu.Game/Migrations/20180913080842_AddRankStatus.cs +++ /dev/null @@ -1,36 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddRankStatus : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Status", - table: "BeatmapSetInfo", - nullable: false, - defaultValue: -3); // NONE - - migrationBuilder.AddColumn( - name: "Status", - table: "BeatmapInfo", - nullable: false, - defaultValue: -3); // NONE - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Status", - table: "BeatmapSetInfo"); - - migrationBuilder.DropColumn( - name: "Status", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20181007180454_StandardizePaths.Designer.cs b/osu.Game/Migrations/20181007180454_StandardizePaths.Designer.cs deleted file mode 100644 index b387a45ecf..0000000000 --- a/osu.Game/Migrations/20181007180454_StandardizePaths.Designer.cs +++ /dev/null @@ -1,380 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20181007180454_StandardizePaths")] - partial class StandardizePaths - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.3-rtm-32065"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20181007180454_StandardizePaths.cs b/osu.Game/Migrations/20181007180454_StandardizePaths.cs deleted file mode 100644 index 30f27043a0..0000000000 --- a/osu.Game/Migrations/20181007180454_StandardizePaths.cs +++ /dev/null @@ -1,27 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class StandardizePaths : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - string windowsStyle = @"\"; - string standardized = "/"; - - // Escaping \ does not seem to be needed. - migrationBuilder.Sql($"UPDATE `BeatmapInfo` SET `Path` = REPLACE(`Path`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `BeatmapMetadata` SET `AudioFile` = REPLACE(`AudioFile`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `BeatmapMetadata` SET `BackgroundFile` = REPLACE(`BackgroundFile`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `BeatmapSetFileInfo` SET `Filename` = REPLACE(`Filename`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `SkinFileInfo` SET `Filename` = REPLACE(`Filename`, '{windowsStyle}', '{standardized}')"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.Designer.cs b/osu.Game/Migrations/20181128100659_AddSkinInfoHash.Designer.cs deleted file mode 100644 index 120674671a..0000000000 --- a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.Designer.cs +++ /dev/null @@ -1,387 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20181128100659_AddSkinInfoHash")] - partial class AddSkinInfoHash - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.4-rtm-31024"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.cs b/osu.Game/Migrations/20181128100659_AddSkinInfoHash.cs deleted file mode 100644 index ee825a1e9c..0000000000 --- a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.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. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSkinInfoHash : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Hash", - table: "SkinInfo", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_SkinInfo_DeletePending", - table: "SkinInfo", - column: "DeletePending"); - - migrationBuilder.CreateIndex( - name: "IX_SkinInfo_Hash", - table: "SkinInfo", - column: "Hash", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_SkinInfo_DeletePending", - table: "SkinInfo"); - - migrationBuilder.DropIndex( - name: "IX_SkinInfo_Hash", - table: "SkinInfo"); - - migrationBuilder.DropColumn( - name: "Hash", - table: "SkinInfo"); - } - } -} diff --git a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.Designer.cs b/osu.Game/Migrations/20181130113755_AddScoreInfoTables.Designer.cs deleted file mode 100644 index eee53182ce..0000000000 --- a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.Designer.cs +++ /dev/null @@ -1,484 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20181130113755_AddScoreInfoTables")] - partial class AddScoreInfoTables - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.4-rtm-31024"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany() - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.cs b/osu.Game/Migrations/20181130113755_AddScoreInfoTables.cs deleted file mode 100644 index 58980132f3..0000000000 --- a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.cs +++ /dev/null @@ -1,115 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddScoreInfoTables : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ScoreInfo", - columns: table => new - { - ID = table.Column(nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Rank = table.Column(nullable: false), - TotalScore = table.Column(nullable: false), - Accuracy = table.Column(type: "DECIMAL(1,4)", nullable: false), - PP = table.Column(nullable: true), - MaxCombo = table.Column(nullable: false), - Combo = table.Column(nullable: false), - RulesetID = table.Column(nullable: false), - Mods = table.Column(nullable: true), - User = table.Column(nullable: true), - BeatmapInfoID = table.Column(nullable: false), - OnlineScoreID = table.Column(nullable: true), - Date = table.Column(nullable: false), - Statistics = table.Column(nullable: true), - Hash = table.Column(nullable: true), - DeletePending = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ScoreInfo", x => x.ID); - table.ForeignKey( - name: "FK_ScoreInfo_BeatmapInfo_BeatmapInfoID", - column: x => x.BeatmapInfoID, - principalTable: "BeatmapInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_ScoreInfo_RulesetInfo_RulesetID", - column: x => x.RulesetID, - principalTable: "RulesetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ScoreFileInfo", - columns: table => new - { - ID = table.Column(nullable: false) - .Annotation("Sqlite:Autoincrement", true), - FileInfoID = table.Column(nullable: false), - Filename = table.Column(nullable: false), - ScoreInfoID = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ScoreFileInfo", x => x.ID); - table.ForeignKey( - name: "FK_ScoreFileInfo_FileInfo_FileInfoID", - column: x => x.FileInfoID, - principalTable: "FileInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_ScoreFileInfo_ScoreInfo_ScoreInfoID", - column: x => x.ScoreInfoID, - principalTable: "ScoreInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_ScoreFileInfo_FileInfoID", - table: "ScoreFileInfo", - column: "FileInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_ScoreFileInfo_ScoreInfoID", - table: "ScoreFileInfo", - column: "ScoreInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_ScoreInfo_BeatmapInfoID", - table: "ScoreInfo", - column: "BeatmapInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_ScoreInfo_OnlineScoreID", - table: "ScoreInfo", - column: "OnlineScoreID", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_ScoreInfo_RulesetID", - table: "ScoreInfo", - column: "RulesetID"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ScoreFileInfo"); - - migrationBuilder.DropTable( - name: "ScoreInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190225062029_AddUserIDColumn.Designer.cs b/osu.Game/Migrations/20190225062029_AddUserIDColumn.Designer.cs deleted file mode 100644 index 8e1e3a59f3..0000000000 --- a/osu.Game/Migrations/20190225062029_AddUserIDColumn.Designer.cs +++ /dev/null @@ -1,487 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190225062029_AddUserIDColumn")] - partial class AddUserIDColumn - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.1-servicing-10028"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190225062029_AddUserIDColumn.cs b/osu.Game/Migrations/20190225062029_AddUserIDColumn.cs deleted file mode 100644 index f2eef600dc..0000000000 --- a/osu.Game/Migrations/20190225062029_AddUserIDColumn.cs +++ /dev/null @@ -1,25 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddUserIDColumn : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "UserID", - table: "ScoreInfo", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "UserID", - table: "ScoreInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190525060824_SkinSettings.Designer.cs b/osu.Game/Migrations/20190525060824_SkinSettings.Designer.cs deleted file mode 100644 index 348c42adb9..0000000000 --- a/osu.Game/Migrations/20190525060824_SkinSettings.Designer.cs +++ /dev/null @@ -1,498 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190525060824_SkinSettings")] - partial class SkinSettings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190525060824_SkinSettings.cs b/osu.Game/Migrations/20190525060824_SkinSettings.cs deleted file mode 100644 index 7779b55bb7..0000000000 --- a/osu.Game/Migrations/20190525060824_SkinSettings.cs +++ /dev/null @@ -1,57 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class SkinSettings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(@"create table Settings_dg_tmp - ( - ID INTEGER not null - constraint PK_Settings - primary key autoincrement, - Key TEXT not null, - RulesetID INTEGER, - Value TEXT, - Variant INTEGER, - SkinInfoID int - constraint Settings_SkinInfo_ID_fk - references SkinInfo - on delete restrict - ); - - insert into Settings_dg_tmp(ID, Key, RulesetID, Value, Variant) select ID, Key, RulesetID, Value, Variant from Settings; - - drop table Settings; - - alter table Settings_dg_tmp rename to Settings; - - create index IX_Settings_RulesetID_Variant - on Settings (RulesetID, Variant); - - create index Settings_SkinInfoID_index - on Settings (SkinInfoID); - - "); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Settings_SkinInfo_SkinInfoID", - table: "Settings"); - - migrationBuilder.DropIndex( - name: "IX_Settings_SkinInfoID", - table: "Settings"); - - migrationBuilder.DropColumn( - name: "SkinInfoID", - table: "Settings"); - } - } -} diff --git a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.Designer.cs b/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.Designer.cs deleted file mode 100644 index 9477369aa0..0000000000 --- a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.Designer.cs +++ /dev/null @@ -1,489 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190605091246_AddDateAddedColumnToBeatmapSet")] - partial class AddDateAddedColumnToBeatmapSet - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.cs b/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.cs deleted file mode 100644 index 0620a0624f..0000000000 --- a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.cs +++ /dev/null @@ -1,27 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddDateAddedColumnToBeatmapSet : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DateAdded", - table: "BeatmapSetInfo", - nullable: false, - defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "DateAdded", - table: "BeatmapSetInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.Designer.cs b/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.Designer.cs deleted file mode 100644 index c5fcc16f84..0000000000 --- a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.Designer.cs +++ /dev/null @@ -1,504 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190708070844_AddBPMAndLengthColumns")] - partial class AddBPMAndLengthColumns - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.cs b/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.cs deleted file mode 100644 index f8ce354aa1..0000000000 --- a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.cs +++ /dev/null @@ -1,36 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddBPMAndLengthColumns : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "BPM", - table: "BeatmapInfo", - nullable: false, - defaultValue: 0.0); - - migrationBuilder.AddColumn( - name: "Length", - table: "BeatmapInfo", - nullable: false, - defaultValue: 0.0); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "BPM", - table: "BeatmapInfo"); - - migrationBuilder.DropColumn( - name: "Length", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs deleted file mode 100644 index 826233a2b0..0000000000 --- a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs +++ /dev/null @@ -1,506 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190913104727_AddBeatmapVideo")] - partial class AddBeatmapVideo - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs deleted file mode 100644 index af82b4db20..0000000000 --- a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs +++ /dev/null @@ -1,25 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddBeatmapVideo : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "VideoFile", - table: "BeatmapMetadata", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "VideoFile", - table: "BeatmapMetadata"); - } - } -} diff --git a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs deleted file mode 100644 index 22316b0380..0000000000 --- a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs +++ /dev/null @@ -1,506 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20200302094919_RefreshVolumeBindings")] - partial class RefreshVolumeBindings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs deleted file mode 100644 index 3d2ddbf6fc..0000000000 --- a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs +++ /dev/null @@ -1,19 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RefreshVolumeBindings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM KeyBinding WHERE action in (6,7)"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs deleted file mode 100644 index 1c05de832e..0000000000 --- a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs +++ /dev/null @@ -1,508 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20201019224408_AddEpilepsyWarning")] - partial class AddEpilepsyWarning - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs deleted file mode 100644 index 58a35a7bf3..0000000000 --- a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs +++ /dev/null @@ -1,26 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddEpilepsyWarning : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "EpilepsyWarning", - table: "BeatmapInfo", - nullable: false, - defaultValue: false); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "EpilepsyWarning", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs deleted file mode 100644 index 2c100d39b9..0000000000 --- a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs +++ /dev/null @@ -1,506 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210412045700_RefreshVolumeBindingsAgain")] - partial class RefreshVolumeBindingsAgain - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs deleted file mode 100644 index 4d3941dd20..0000000000 --- a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs +++ /dev/null @@ -1,19 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RefreshVolumeBindingsAgain : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM KeyBinding WHERE action in (6,7)"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs deleted file mode 100644 index b808c648da..0000000000 --- a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs +++ /dev/null @@ -1,508 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210511060743_AddSkinInstantiationInfo")] - partial class AddSkinInstantiationInfo - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs deleted file mode 100644 index 887635fa85..0000000000 --- a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs +++ /dev/null @@ -1,25 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSkinInstantiationInfo : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "InstantiationInfo", - table: "SkinInfo", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "InstantiationInfo", - table: "SkinInfo"); - } - } -} diff --git a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs deleted file mode 100644 index 89bab3a0fa..0000000000 --- a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs +++ /dev/null @@ -1,511 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210514062639_AddAuthorIdToBeatmapMetadata")] - partial class AddAuthorIdToBeatmapMetadata - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs deleted file mode 100644 index 7b579e27b9..0000000000 --- a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs +++ /dev/null @@ -1,26 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddAuthorIdToBeatmapMetadata : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "AuthorID", - table: "BeatmapMetadata", - nullable: false, - defaultValue: 0); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "AuthorID", - table: "BeatmapMetadata"); - } - } -} diff --git a/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs b/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs deleted file mode 100644 index afeb42130d..0000000000 --- a/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs +++ /dev/null @@ -1,513 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210824185035_AddCountdownSettings")] - partial class AddCountdownSettings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("CountdownOffset"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs b/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs deleted file mode 100644 index d1b09e2c1d..0000000000 --- a/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs +++ /dev/null @@ -1,26 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddCountdownSettings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CountdownOffset", - table: "BeatmapInfo", - nullable: false, - defaultValue: 0); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CountdownOffset", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs deleted file mode 100644 index 6e53d7fae0..0000000000 --- a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs +++ /dev/null @@ -1,515 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210912144011_AddSamplesMatchPlaybackRate")] - partial class AddSamplesMatchPlaybackRate - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("CountdownOffset"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SamplesMatchPlaybackRate"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs deleted file mode 100644 index f6fc1f4420..0000000000 --- a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs +++ /dev/null @@ -1,26 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSamplesMatchPlaybackRate : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SamplesMatchPlaybackRate", - table: "BeatmapInfo", - nullable: false, - defaultValue: false); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SamplesMatchPlaybackRate", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20211020081609_ResetSkinHashes.cs b/osu.Game/Migrations/20211020081609_ResetSkinHashes.cs deleted file mode 100644 index 6d53c019ec..0000000000 --- a/osu.Game/Migrations/20211020081609_ResetSkinHashes.cs +++ /dev/null @@ -1,23 +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 Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20211020081609_ResetSkinHashes")] - public partial class ResetSkinHashes : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql($"UPDATE SkinInfo SET Hash = null"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs deleted file mode 100644 index 036c26cb0a..0000000000 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ /dev/null @@ -1,513 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - partial class OsuDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("CountdownOffset"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SamplesMatchPlaybackRate"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Models/RealmUser.cs b/osu.Game/Models/RealmUser.cs index 58fd7ff2a3..e20ffc0808 100644 --- a/osu.Game/Models/RealmUser.cs +++ b/osu.Game/Models/RealmUser.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; using osu.Game.Database; using osu.Game.Users; @@ -17,6 +15,16 @@ namespace osu.Game.Models public string Username { get; set; } = string.Empty; + [Ignored] + public CountryCode CountryCode + { + get => Enum.TryParse(CountryString, out CountryCode country) ? country : CountryCode.Unknown; + set => CountryString = value.ToString(); + } + + [MapTo(nameof(CountryCode))] + public string CountryString { get; set; } = default(CountryCode).ToString(); + public bool IsBot => false; public bool Equals(RealmUser other) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 088dc56701..a0c8e0d555 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -38,7 +38,7 @@ namespace osu.Game.Online.API public string WebsiteRootUrl { get; } - public int APIVersion => 20220217; // We may want to pull this from the game version eventually. + public int APIVersion => 20220705; // We may want to pull this from the game version eventually. public Exception LastLoginError { get; private set; } @@ -104,114 +104,39 @@ namespace osu.Game.Online.API /// private int failureCount; + /// + /// The main API thread loop, which will continue to run until the game is shut down. + /// private void run() { while (!cancellationToken.IsCancellationRequested) { - switch (State.Value) + if (state.Value == APIState.Failing) { - case APIState.Failing: - //todo: replace this with a ping request. - log.Add(@"In a failing state, waiting a bit before we try again..."); - Thread.Sleep(5000); + // To recover from a failing state, falling through and running the full reconnection process seems safest for now. + // This could probably be replaced with a ping-style request if we want to avoid the reconnection overheads. + log.Add($@"{nameof(APIAccess)} is in a failing state, waiting a bit before we try again..."); + Thread.Sleep(5000); + } - if (!IsLoggedIn) goto case APIState.Connecting; + // Ensure that we have valid credentials. + // If not, setting the offline state will allow the game to prompt the user to provide new credentials. + if (!HasLogin) + { + state.Value = APIState.Offline; + Thread.Sleep(50); + continue; + } - if (queue.Count == 0) - { - log.Add(@"Queueing a ping request"); - Queue(new GetUserRequest()); - } + Debug.Assert(HasLogin); - break; + // Ensure that we are in an online state. If not, attempt a connect. + if (state.Value != APIState.Online) + { + attemptConnect(); - case APIState.Offline: - case APIState.Connecting: - // work to restore a connection... - if (!HasLogin) - { - state.Value = APIState.Offline; - Thread.Sleep(50); - continue; - } - - state.Value = APIState.Connecting; - - // save the username at this point, if the user requested for it to be. - config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); - - if (!authentication.HasValidAccessToken) - { - LastLoginError = null; - - try - { - authentication.AuthenticateWithLogin(ProvidedUsername, password); - } - catch (Exception e) - { - //todo: this fails even on network-related issues. we should probably handle those differently. - LastLoginError = e; - log.Add(@"Login failed!"); - password = null; - authentication.Clear(); - continue; - } - } - - var userReq = new GetUserRequest(); - - userReq.Failure += ex => - { - if (ex is WebException webException && webException.Message == @"Unauthorized") - { - log.Add(@"Login no longer valid"); - Logout(); - } - else - failConnectionProcess(); - }; - userReq.Success += u => - { - localUser.Value = u; - - // todo: save/pull from settings - localUser.Value.Status.Value = new UserStatusOnline(); - - failureCount = 0; - }; - - if (!handleRequest(userReq)) - { - failConnectionProcess(); - continue; - } - - // getting user's friends is considered part of the connection process. - var friendsReq = new GetFriendsRequest(); - - friendsReq.Failure += _ => failConnectionProcess(); - friendsReq.Success += res => - { - friends.AddRange(res); - - //we're connected! - state.Value = APIState.Online; - }; - - if (!handleRequest(friendsReq)) - { - failConnectionProcess(); - continue; - } - - // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. - // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests - // before actually going online. - while (State.Value > APIState.Offline && State.Value < APIState.Online) - Thread.Sleep(500); - - break; + if (state.Value != APIState.Online) + continue; } // hard bail if we can't get a valid access token. @@ -221,31 +146,132 @@ namespace osu.Game.Online.API continue; } - while (true) - { - APIRequest req; - - lock (queue) - { - if (queue.Count == 0) break; - - req = queue.Dequeue(); - } - - handleRequest(req); - } - + processQueuedRequests(); Thread.Sleep(50); } + } - void failConnectionProcess() + /// + /// Dequeue from the queue and run each request synchronously until the queue is empty. + /// + private void processQueuedRequests() + { + while (true) { - // if something went wrong during the connection process, we want to reset the state (but only if still connecting). - if (State.Value == APIState.Connecting) - state.Value = APIState.Failing; + APIRequest req; + + lock (queue) + { + if (queue.Count == 0) return; + + req = queue.Dequeue(); + } + + handleRequest(req); } } + /// + /// From a non-connected state, perform a full connection flow, obtaining OAuth tokens and populating the local user and friends. + /// + /// + /// This method takes control of and transitions from to either + /// - (successful connection) + /// - (failed connection but retrying) + /// - (failed and can't retry, clear credentials and require user interaction) + /// + /// Whether the connection attempt was successful. + private void attemptConnect() + { + state.Value = APIState.Connecting; + + if (localUser.IsDefault) + { + // Show a placeholder user if saved credentials are available. + // This is useful for storing local scores and showing a placeholder username after starting the game, + // until a valid connection has been established. + setLocalUser(new APIUser + { + Username = ProvidedUsername, + }); + } + + // save the username at this point, if the user requested for it to be. + config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); + + if (!authentication.HasValidAccessToken) + { + LastLoginError = null; + + try + { + authentication.AuthenticateWithLogin(ProvidedUsername, password); + } + catch (Exception e) + { + //todo: this fails even on network-related issues. we should probably handle those differently. + LastLoginError = e; + log.Add($@"Login failed for username {ProvidedUsername} ({LastLoginError.Message})!"); + + Logout(); + return; + } + } + + var userReq = new GetUserRequest(); + userReq.Failure += ex => + { + if (ex is APIException) + { + LastLoginError = ex; + log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); + Logout(); + } + else if (ex is WebException webException && webException.Message == @"Unauthorized") + { + log.Add(@"Login no longer valid"); + Logout(); + } + else + { + state.Value = APIState.Failing; + } + }; + userReq.Success += user => + { + // todo: save/pull from settings + user.Status.Value = new UserStatusOnline(); + + setLocalUser(user); + + // we're connected! + state.Value = APIState.Online; + failureCount = 0; + }; + + if (!handleRequest(userReq)) + { + state.Value = APIState.Failing; + return; + } + + var friendsReq = new GetFriendsRequest(); + friendsReq.Failure += _ => state.Value = APIState.Failing; + friendsReq.Success += res => friends.AddRange(res); + + if (!handleRequest(friendsReq)) + { + state.Value = APIState.Failing; + return; + } + + // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. + // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests + // before actually going online. + while (State.Value == APIState.Connecting && !cancellationToken.IsCancellationRequested) + Thread.Sleep(500); + } + public void Perform(APIRequest request) { try @@ -321,8 +347,7 @@ namespace osu.Game.Online.API if (req.CompletionState != APIRequestCompletionState.Completed) return false; - // we could still be in initialisation, at which point we don't want to say we're Online yet. - if (IsLoggedIn) state.Value = APIState.Online; + // Reset failure count if this request succeeded. failureCount = 0; return true; } @@ -396,7 +421,7 @@ namespace osu.Game.Online.API } } - public bool IsLoggedIn => localUser.Value.Id > 1; // TODO: should this also be true if attempting to connect? + public bool IsLoggedIn => State.Value > APIState.Offline; public void Queue(APIRequest request) { @@ -436,7 +461,7 @@ namespace osu.Game.Online.API // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present Schedule(() => { - localUser.Value = createGuestUser(); + setLocalUser(createGuestUser()); friends.Clear(); }); @@ -446,6 +471,8 @@ namespace osu.Game.Online.API private static APIUser createGuestUser() => new GuestUser(); + private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index dc1db08174..176f10975d 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -1,17 +1,15 @@ // 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 Humanizer; using MessagePack; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -22,7 +20,7 @@ namespace osu.Game.Online.API { [JsonProperty("acronym")] [Key(0)] - public string Acronym { get; set; } + public string Acronym { get; set; } = string.Empty; [JsonProperty("settings")] [Key(1)] @@ -44,13 +42,13 @@ namespace osu.Game.Online.API var bindable = (IBindable)property.GetValue(mod); if (!bindable.IsDefault) - Settings.Add(property.Name.Underscore(), bindable.GetUnderlyingSettingValue()); + Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue()); } } public Mod ToMod(Ruleset ruleset) { - Mod resultMod = ruleset.CreateModFromAcronym(Acronym); + Mod? resultMod = ruleset.CreateModFromAcronym(Acronym); if (resultMod == null) { @@ -62,16 +60,25 @@ namespace osu.Game.Online.API { foreach (var (_, property) in resultMod.GetSettingsSourceProperties()) { - if (!Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + if (!Settings.TryGetValue(property.Name.ToSnakeCase(), out object settingValue)) continue; - resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod), settingValue); + try + { + resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod), settingValue); + } + catch (Exception ex) + { + Logger.Log($"Failed to copy mod setting value '{settingValue ?? "null"}' to \"{property.Name}\": {ex.Message}"); + } } } return resultMod; } + public bool ShouldSerializeSettings() => Settings.Count > 0; + public bool Equals(APIMod other) { if (ReferenceEquals(null, other)) return false; 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/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 812aa7f09f..a90b11e354 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -13,19 +13,16 @@ namespace osu.Game.Online.API { /// /// The local user. - /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// IBindable LocalUser { get; } /// /// The user's friends. - /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// IBindableList Friends { get; } /// /// The current user's activity. - /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// IBindable Activity { get; } diff --git a/osu.Game/Online/API/Requests/CommentDeleteRequest.cs b/osu.Game/Online/API/Requests/CommentDeleteRequest.cs new file mode 100644 index 0000000000..b150a6d5fc --- /dev/null +++ b/osu.Game/Online/API/Requests/CommentDeleteRequest.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 System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class CommentDeleteRequest : APIRequest + { + public readonly long CommentId; + + public CommentDeleteRequest(long id) + { + CommentId = id; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"comments/{CommentId}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index efecc0fc25..3383d21dfc 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -9,27 +9,25 @@ namespace osu.Game.Online.API.Requests { public class GetBeatmapRequest : APIRequest { - private readonly IBeatmapInfo beatmapInfo; - - private readonly string filename; + public readonly IBeatmapInfo BeatmapInfo; + public readonly string Filename; public GetBeatmapRequest(IBeatmapInfo beatmapInfo) { - this.beatmapInfo = beatmapInfo; - - filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; + BeatmapInfo = beatmapInfo; + Filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; } protected override WebRequest CreateWebRequest() { var request = base.CreateWebRequest(); - if (beatmapInfo.OnlineID > 0) - request.AddParameter(@"id", beatmapInfo.OnlineID.ToString()); - if (!string.IsNullOrEmpty(beatmapInfo.MD5Hash)) - request.AddParameter(@"checksum", beatmapInfo.MD5Hash); - if (!string.IsNullOrEmpty(filename)) - request.AddParameter(@"filename", filename); + if (BeatmapInfo.OnlineID > 0) + request.AddParameter(@"id", BeatmapInfo.OnlineID.ToString()); + if (!string.IsNullOrEmpty(BeatmapInfo.MD5Hash)) + request.AddParameter(@"checksum", BeatmapInfo.MD5Hash); + if (!string.IsNullOrEmpty(Filename)) + request.AddParameter(@"filename", Filename); return request; } diff --git a/osu.Game/Online/API/Requests/GetCommentsRequest.cs b/osu.Game/Online/API/Requests/GetCommentsRequest.cs index c63c574124..1aa08f2ed8 100644 --- a/osu.Game/Online/API/Requests/GetCommentsRequest.cs +++ b/osu.Game/Online/API/Requests/GetCommentsRequest.cs @@ -4,7 +4,7 @@ #nullable disable using osu.Framework.IO.Network; -using Humanizer; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Comments; @@ -32,7 +32,7 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); req.AddParameter("commentable_id", commentableId.ToString()); - req.AddParameter("commentable_type", type.ToString().Underscore().ToLowerInvariant()); + req.AddParameter("commentable_type", type.ToString().ToSnakeCase().ToLowerInvariant()); req.AddParameter("page", page.ToString()); req.AddParameter("sort", sort.ToString().ToLowerInvariant()); diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index a6cd9a52c7..966e69938c 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -35,7 +35,7 @@ namespace osu.Game.Online.API.Requests this.mods = mods ?? Array.Empty(); } - protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/scores{createQueryParameters()}"; + protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/solo-scores{createQueryParameters()}"; private string createQueryParameters() { diff --git a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs index 3ec60cd06c..d723786f23 100644 --- a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs @@ -3,8 +3,8 @@ #nullable disable -using Humanizer; using System.Collections.Generic; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests @@ -22,7 +22,7 @@ namespace osu.Game.Online.API.Requests this.type = type; } - protected override string Target => $@"users/{userId}/beatmapsets/{type.ToString().Underscore()}"; + protected override string Target => $@"users/{userId}/beatmapsets/{type.ToString().ToSnakeCase()}"; } public enum BeatmapSetType diff --git a/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs b/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs index ab0cc3a56d..c27a83b695 100644 --- a/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs @@ -5,6 +5,7 @@ using osu.Framework.IO.Network; using osu.Game.Rulesets; +using osu.Game.Users; namespace osu.Game.Online.API.Requests { @@ -12,21 +13,21 @@ namespace osu.Game.Online.API.Requests { public readonly UserRankingsType Type; - private readonly string country; + private readonly CountryCode countryCode; - public GetUserRankingsRequest(RulesetInfo ruleset, UserRankingsType type = UserRankingsType.Performance, int page = 1, string country = null) + public GetUserRankingsRequest(RulesetInfo ruleset, UserRankingsType type = UserRankingsType.Performance, int page = 1, CountryCode countryCode = CountryCode.Unknown) : base(ruleset, page) { Type = type; - this.country = country; + this.countryCode = countryCode; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - if (country != null) - req.AddParameter("country", country); + if (countryCode != CountryCode.Unknown) + req.AddParameter("country", countryCode.ToString()); return req; } diff --git a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs index 9bd78b7be1..8ef797f799 100644 --- a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { - public class GetUserScoresRequest : PaginatedAPIRequest> + public class GetUserScoresRequest : PaginatedAPIRequest> { private readonly long userId; private readonly ScoreType type; diff --git a/osu.Game/Online/API/Requests/GetWikiRequest.cs b/osu.Game/Online/API/Requests/GetWikiRequest.cs index e0f967ec15..7c84e1f790 100644 --- a/osu.Game/Online/API/Requests/GetWikiRequest.cs +++ b/osu.Game/Online/API/Requests/GetWikiRequest.cs @@ -11,15 +11,16 @@ namespace osu.Game.Online.API.Requests { public class GetWikiRequest : APIRequest { - private readonly string path; + public readonly string Path; + private readonly Language language; public GetWikiRequest(string path, Language language = Language.en) { - this.path = path; + Path = path; this.language = language; } - protected override string Target => $"wiki/{language.ToCultureCode()}/{path}"; + protected override string Target => $"wiki/{language.ToCultureCode()}/{Path}"; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 735fde333d..8a77801c3a 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -81,6 +81,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"max_combo")] public int? MaxCombo { get; set; } + [JsonProperty(@"last_updated")] + public DateTimeOffset LastUpdated { get; set; } + public double BPM { get; set; } #region Implementation of IBeatmapInfo @@ -108,7 +111,7 @@ namespace osu.Game.Online.API.Requests.Responses public bool Equals(IBeatmapInfo? other) => other is APIBeatmap b && this.MatchesOnlineID(b); - private class APIRuleset : IRulesetInfo + public class APIRuleset : IRulesetInfo { public int OnlineID { get; set; } = -1; diff --git a/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs b/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs index 8fefe4d9c2..2def18926f 100644 --- a/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs +++ b/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs @@ -4,8 +4,8 @@ #nullable disable using System; -using Humanizer; using Newtonsoft.Json; +using osu.Game.Extensions; using osu.Game.Scoring; namespace osu.Game.Online.API.Requests.Responses @@ -21,7 +21,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty] private string type { - set => Type = (RecentActivityType)Enum.Parse(typeof(RecentActivityType), value.Pascalize()); + set => Type = (RecentActivityType)Enum.Parse(typeof(RecentActivityType), value.ToPascalCase()); } public RecentActivityType Type; diff --git a/osu.Game/Online/API/Requests/Responses/APIScore.cs b/osu.Game/Online/API/Requests/Responses/APIScore.cs deleted file mode 100644 index f236607761..0000000000 --- a/osu.Game/Online/API/Requests/Responses/APIScore.cs +++ /dev/null @@ -1,162 +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 System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Scoring; -using osu.Game.Scoring.Legacy; -using osu.Game.Users; - -namespace osu.Game.Online.API.Requests.Responses -{ - public class APIScore : IScoreInfo - { - [JsonProperty(@"score")] - public long TotalScore { get; set; } - - [JsonProperty(@"max_combo")] - public int MaxCombo { get; set; } - - [JsonProperty(@"user")] - public APIUser User { get; set; } - - [JsonProperty(@"id")] - public long OnlineID { get; set; } - - [JsonProperty(@"replay")] - public bool HasReplay { get; set; } - - [JsonProperty(@"created_at")] - public DateTimeOffset Date { get; set; } - - [JsonProperty(@"beatmap")] - [CanBeNull] - public APIBeatmap Beatmap { get; set; } - - [JsonProperty("accuracy")] - public double Accuracy { get; set; } - - [JsonProperty(@"pp")] - public double? PP { get; set; } - - [JsonProperty(@"beatmapset")] - [CanBeNull] - public APIBeatmapSet BeatmapSet - { - set - { - // in the deserialisation case we need to ferry this data across. - // the order of properties returned by the API guarantees that the beatmap is populated by this point. - if (!(Beatmap is APIBeatmap apiBeatmap)) - throw new InvalidOperationException("Beatmap set metadata arrived before beatmap metadata in response"); - - apiBeatmap.BeatmapSet = value; - } - } - - [JsonProperty("statistics")] - public Dictionary Statistics { get; set; } - - [JsonProperty(@"mode_int")] - public int RulesetID { get; set; } - - [JsonProperty(@"mods")] - private string[] mods { set => Mods = value.Select(acronym => new APIMod { Acronym = acronym }); } - - [NotNull] - public IEnumerable Mods { get; set; } = Array.Empty(); - - [JsonProperty("rank")] - [JsonConverter(typeof(StringEnumConverter))] - public ScoreRank Rank { get; set; } - - /// - /// Create a from an API score instance. - /// - /// A ruleset store, used to populate a ruleset instance in the returned score. - /// An optional beatmap, copied into the returned score (for cases where the API does not populate the beatmap). - /// - public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null) - { - var ruleset = rulesets.GetRuleset(RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {RulesetID} not found locally"); - - var rulesetInstance = ruleset.CreateInstance(); - - var modInstances = Mods.Select(apiMod => rulesetInstance.CreateModFromAcronym(apiMod.Acronym)).Where(m => m != null).ToArray(); - - // all API scores provided by this class are considered to be legacy. - modInstances = modInstances.Append(rulesetInstance.CreateMod()).ToArray(); - - var scoreInfo = new ScoreInfo - { - TotalScore = TotalScore, - MaxCombo = MaxCombo, - BeatmapInfo = beatmap ?? new BeatmapInfo(), - User = User, - Accuracy = Accuracy, - OnlineID = OnlineID, - Date = Date, - PP = PP, - Hash = HasReplay ? "online" : string.Empty, // todo: temporary? - Rank = Rank, - Ruleset = ruleset, - Mods = modInstances, - }; - - if (Statistics != null) - { - foreach (var kvp in Statistics) - { - switch (kvp.Key) - { - case @"count_geki": - scoreInfo.SetCountGeki(kvp.Value); - break; - - case @"count_300": - scoreInfo.SetCount300(kvp.Value); - break; - - case @"count_katu": - scoreInfo.SetCountKatu(kvp.Value); - break; - - case @"count_100": - scoreInfo.SetCount100(kvp.Value); - break; - - case @"count_50": - scoreInfo.SetCount50(kvp.Value); - break; - - case @"count_miss": - scoreInfo.SetCountMiss(kvp.Value); - break; - } - } - } - - return scoreInfo; - } - - public IRulesetInfo Ruleset => new RulesetInfo { OnlineID = RulesetID }; - IEnumerable IHasNamedFiles.Files => throw new NotImplementedException(); - - #region Implementation of IScoreInfo - - IBeatmapInfo IScoreInfo.Beatmap => Beatmap; - IUser IScoreInfo.User => User; - - #endregion - } -} diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs index 8bd54f889d..494826f534 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs @@ -16,11 +16,11 @@ namespace osu.Game.Online.API.Requests.Responses public int? Position; [JsonProperty(@"score")] - public APIScore Score; + public SoloScoreInfo Score; public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null) { - var score = Score.CreateScoreInfo(rulesets, beatmap); + var score = Score.ToScoreInfo(rulesets, beatmap); score.Position = Position; return score; } diff --git a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs index 9c8a38c63a..4ef39be5e5 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs @@ -11,9 +11,9 @@ namespace osu.Game.Online.API.Requests.Responses public class APIScoresCollection { [JsonProperty(@"scores")] - public List Scores; + public List Scores; - [JsonProperty(@"userScore")] + [JsonProperty(@"user_score")] public APIScoreWithPosition UserScore; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 63aaa9b90e..d3ddcffaf5 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -34,8 +34,19 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"previous_usernames")] public string[] PreviousUsernames; + private CountryCode? countryCode; + + public CountryCode CountryCode + { + get => countryCode ??= (Enum.TryParse(country?.Code, out CountryCode result) ? result : default); + set => countryCode = value; + } + +#pragma warning disable 649 + [CanBeNull] [JsonProperty(@"country")] - public Country Country; + private Country country; +#pragma warning restore 649 public readonly Bindable Status = new Bindable(); @@ -217,7 +228,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"rank_history")] private APIRankHistory rankHistory { - set => statistics.RankHistory = value; + set => Statistics.RankHistory = value; } [JsonProperty("badges")] @@ -256,5 +267,13 @@ namespace osu.Game.Online.API.Requests.Responses public int OnlineID => Id; public bool Equals(APIUser other) => this.MatchesOnlineID(other); + +#pragma warning disable 649 + private class Country + { + [JsonProperty(@"code")] + public string Code; + } +#pragma warning restore 649 } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs index 30433ab8cd..c870157fec 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs @@ -10,6 +10,9 @@ using osu.Game.Scoring; namespace osu.Game.Online.API.Requests.Responses { + /// + /// Represents an aggregate score for a user based off all beatmaps that have been played in the playlist. + /// public class APIUserScoreAggregate { [JsonProperty("attempts")] diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs index 500c0566e6..907632186c 100644 --- a/osu.Game/Online/API/Requests/Responses/Comment.cs +++ b/osu.Game/Online/API/Requests/Responses/Comment.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 Newtonsoft.Json; using System; @@ -16,18 +14,18 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"parent_id")] public long? ParentId { get; set; } - public Comment ParentComment { get; set; } + public Comment? ParentComment { get; set; } [JsonProperty(@"user_id")] public long? UserId { get; set; } - public APIUser User { get; set; } + public APIUser? User { get; set; } [JsonProperty(@"message")] - public string Message { get; set; } + public string Message { get; set; } = null!; [JsonProperty(@"message_html")] - public string MessageHtml { get; set; } + public string? MessageHtml { get; set; } [JsonProperty(@"replies_count")] public int RepliesCount { get; set; } @@ -36,13 +34,13 @@ namespace osu.Game.Online.API.Requests.Responses public int VotesCount { get; set; } [JsonProperty(@"commenatble_type")] - public string CommentableType { get; set; } + public string CommentableType { get; set; } = null!; [JsonProperty(@"commentable_id")] public int CommentableId { get; set; } [JsonProperty(@"legacy_name")] - public string LegacyName { get; set; } + public string? LegacyName { get; set; } [JsonProperty(@"created_at")] public DateTimeOffset CreatedAt { get; set; } @@ -62,7 +60,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"pinned")] public bool Pinned { get; set; } - public APIUser EditedUser { get; set; } + public APIUser? EditedUser { get; set; } public bool IsTopLevel => !ParentId.HasValue; diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs new file mode 100644 index 0000000000..4469d50acb --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -0,0 +1,208 @@ +// 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 Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Online.API.Requests.Responses +{ + [Serializable] + public class SoloScoreInfo : IHasOnlineID + { + [JsonProperty("beatmap_id")] + public int BeatmapID { get; set; } + + [JsonProperty("ruleset_id")] + public int RulesetID { get; set; } + + [JsonProperty("build_id")] + public int? BuildID { get; set; } + + [JsonProperty("passed")] + public bool Passed { get; set; } + + [JsonProperty("total_score")] + public int TotalScore { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty("user_id")] + public int UserID { get; set; } + + // TODO: probably want to update this column to match user stats (short)? + [JsonProperty("max_combo")] + public int MaxCombo { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("rank")] + public ScoreRank Rank { get; set; } + + [JsonProperty("started_at")] + public DateTimeOffset? StartedAt { get; set; } + + [JsonProperty("ended_at")] + public DateTimeOffset EndedAt { get; set; } + + [JsonProperty("mods")] + public APIMod[] Mods { get; set; } = Array.Empty(); + + [JsonIgnore] + [JsonProperty("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonIgnore] + [JsonProperty("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + [JsonIgnore] + [JsonProperty("deleted_at")] + public DateTimeOffset? DeletedAt { get; set; } + + [JsonProperty("statistics")] + public Dictionary Statistics { get; set; } = new Dictionary(); + + [JsonProperty("maximum_statistics")] + public Dictionary MaximumStatistics { get; set; } = new Dictionary(); + + /// + /// Used to preserve the total score for legacy scores. + /// + [JsonProperty("legacy_total_score")] + public int? LegacyTotalScore { get; set; } + + [JsonProperty("legacy_score_id")] + public ulong? LegacyScoreId { get; set; } + + #region osu-web API additions (not stored to database). + + [JsonProperty("id")] + public ulong? ID { get; set; } + + [JsonProperty("user")] + public APIUser? User { get; set; } + + [JsonProperty("beatmap")] + public APIBeatmap? Beatmap { get; set; } + + [JsonProperty("beatmapset")] + public APIBeatmapSet? BeatmapSet + { + set + { + // in the deserialisation case we need to ferry this data across. + // the order of properties returned by the API guarantees that the beatmap is populated by this point. + if (!(Beatmap is APIBeatmap apiBeatmap)) + throw new InvalidOperationException("Beatmap set metadata arrived before beatmap metadata in response"); + + apiBeatmap.BeatmapSet = value; + } + } + + [JsonProperty("pp")] + public double? PP { get; set; } + + [JsonProperty("has_replay")] + public bool HasReplay { get; set; } + + // These properties are calculated or not relevant to any external usage. + public bool ShouldSerializeID() => false; + public bool ShouldSerializeUser() => false; + public bool ShouldSerializeBeatmap() => false; + public bool ShouldSerializeBeatmapSet() => false; + public bool ShouldSerializePP() => false; + public bool ShouldSerializeOnlineID() => false; + public bool ShouldSerializeHasReplay() => false; + + // These fields only need to be serialised if they hold values. + // Generally this is required because this model may be used by server-side components, but + // we don't want to bother sending these fields in score submission requests, for instance. + public bool ShouldSerializeEndedAt() => EndedAt != default; + public bool ShouldSerializeStartedAt() => StartedAt != default; + public bool ShouldSerializeLegacyScoreId() => LegacyScoreId != null; + public bool ShouldSerializeLegacyTotalScore() => LegacyTotalScore != null; + public bool ShouldSerializeMods() => Mods.Length > 0; + public bool ShouldSerializeUserID() => UserID > 0; + public bool ShouldSerializeBeatmapID() => BeatmapID > 0; + public bool ShouldSerializeBuildID() => BuildID != null; + + #endregion + + public override string ToString() => $"score_id: {ID} user_id: {UserID}"; + + /// + /// Create a from an API score instance. + /// + /// A ruleset store, used to populate a ruleset instance in the returned score. + /// An optional beatmap, copied into the returned score (for cases where the API does not populate the beatmap). + /// + public ScoreInfo ToScoreInfo(RulesetStore rulesets, BeatmapInfo? beatmap = null) + { + var ruleset = rulesets.GetRuleset(RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {RulesetID} not found locally"); + + var rulesetInstance = ruleset.CreateInstance(); + + var mods = Mods.Select(apiMod => apiMod.ToMod(rulesetInstance)).ToArray(); + + var scoreInfo = ToScoreInfo(mods); + + scoreInfo.Ruleset = ruleset; + if (beatmap != null) scoreInfo.BeatmapInfo = beatmap; + + return scoreInfo; + } + + /// + /// Create a from an API score instance. + /// + /// The mod instances, resolved from a ruleset. + /// + public ScoreInfo ToScoreInfo(Mod[] mods) => new ScoreInfo + { + OnlineID = OnlineID, + User = User ?? new APIUser { Id = UserID }, + BeatmapInfo = new BeatmapInfo { OnlineID = BeatmapID }, + Ruleset = new RulesetInfo { OnlineID = RulesetID }, + Passed = Passed, + TotalScore = TotalScore, + Accuracy = Accuracy, + MaxCombo = MaxCombo, + Rank = Rank, + Statistics = Statistics, + MaximumStatistics = MaximumStatistics, + Date = EndedAt, + Hash = HasReplay ? "online" : string.Empty, // TODO: temporary? + Mods = mods, + PP = PP, + }; + + /// + /// Creates a from a local score for score submission. + /// + /// The local score. + public static SoloScoreInfo ForSubmission(ScoreInfo score) => new SoloScoreInfo + { + Rank = score.Rank, + TotalScore = (int)score.TotalScore, + Accuracy = score.Accuracy, + PP = score.PP, + MaxCombo = score.MaxCombo, + RulesetID = score.RulesetID, + 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 => (long?)ID ?? -1; + } +} diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index 082f9bb371..c303c410ec 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; -using Humanizer; using JetBrains.Annotations; using osu.Framework.IO.Network; using osu.Game.Extensions; @@ -86,7 +85,7 @@ namespace osu.Game.Online.API.Requests req.AddParameter("q", query); if (General != null && General.Any()) - req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().Underscore()))); + req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().ToSnakeCase()))); if (ruleset.OnlineID >= 0) req.AddParameter("m", ruleset.OnlineID.ToString()); 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/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 3fc6a008e8..22c2b4690e 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -174,7 +174,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours, ChatOverlay chatOverlay, INotificationOverlay notificationOverlay) { - IconBackground.Colour = colours.PurpleDark; + IconContent.Colour = colours.PurpleDark; Activated = delegate { diff --git a/osu.Game/Online/DownloadState.cs b/osu.Game/Online/DownloadState.cs index 3d389d45f9..f4ecb28b90 100644 --- a/osu.Game/Online/DownloadState.cs +++ b/osu.Game/Online/DownloadState.cs @@ -1,12 +1,11 @@ // 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.Online { public enum DownloadState { + Unknown, NotDownloaded, Downloading, Importing, diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 01f0f3a902..6bfe09e911 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -64,26 +64,26 @@ namespace osu.Game.Online this.preferMessagePack = preferMessagePack; apiState.BindTo(api.State); - apiState.BindValueChanged(_ => connectIfPossible(), true); + apiState.BindValueChanged(_ => Task.Run(connectIfPossible), true); } - public void Reconnect() + public Task Reconnect() { Logger.Log($"{clientName} reconnecting...", LoggingTarget.Network); - Task.Run(connectIfPossible); + return Task.Run(connectIfPossible); } - private void connectIfPossible() + private async Task connectIfPossible() { switch (apiState.Value) { case APIState.Failing: case APIState.Offline: - Task.Run(() => disconnect(true)); + await disconnect(true); break; case APIState.Online: - Task.Run(connect); + await connect(); break; } } diff --git a/osu.Game/Online/IHubClientConnector.cs b/osu.Game/Online/IHubClientConnector.cs index 2afab9091b..53c4897e73 100644 --- a/osu.Game/Online/IHubClientConnector.cs +++ b/osu.Game/Online/IHubClientConnector.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Bindables; using osu.Game.Online.API; @@ -32,6 +33,6 @@ namespace osu.Game.Online /// /// Reconnect if already connected. /// - void Reconnect(); + Task Reconnect(); } } diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 58b1ea62aa..69b4e5b209 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -1,14 +1,11 @@ // 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 System.Threading; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -39,7 +36,9 @@ namespace osu.Game.Online.Leaderboards /// /// The currently displayed scores. /// - public IEnumerable Scores => scores; + public IBindableList Scores => scores; + + private readonly BindableList scores = new BindableList(); /// /// Whether the current scope should refetch in response to changes in API connectivity state. @@ -52,25 +51,23 @@ namespace osu.Game.Online.Leaderboards private readonly Container placeholderContainer; private readonly UserTopScoreContainer userScoreContainer; - private FillFlowContainer scoreFlowContainer; + private FillFlowContainer? scoreFlowContainer; private readonly LoadingSpinner loading; - private CancellationTokenSource currentFetchCancellationSource; - private CancellationTokenSource currentScoresAsyncLoadCancellationSource; + private CancellationTokenSource? currentFetchCancellationSource; + private CancellationTokenSource? currentScoresAsyncLoadCancellationSource; - private APIRequest fetchScoresRequest; + private APIRequest? fetchScoresRequest; private LeaderboardState state; [Resolved(CanBeNull = true)] - private IAPIProvider api { get; set; } + private IAPIProvider? api { get; set; } private readonly IBindable apiState = new Bindable(); - private ICollection scores; - - private TScope scope; + private TScope scope = default!; public TScope Scope { @@ -169,7 +166,7 @@ namespace osu.Game.Online.Leaderboards throw new InvalidOperationException($"State {state} cannot be set by a leaderboard implementation."); } - Debug.Assert(scores?.Any() != true); + Debug.Assert(!scores.Any()); setState(state); } @@ -179,17 +176,33 @@ namespace osu.Game.Online.Leaderboards /// /// The scores to display. /// The user top score, if any. - protected void SetScores(IEnumerable scores, TScoreInfo userScore = default) + protected void SetScores(IEnumerable? scores, TScoreInfo? userScore = default) { - this.scores = scores?.ToList(); - userScoreContainer.Score.Value = userScore; + this.scores.Clear(); + if (scores != null) + this.scores.AddRange(scores); - if (userScore == null) - userScoreContainer.Hide(); - else - userScoreContainer.Show(); + // Non-delayed schedule may potentially run inline (due to IsMainThread check passing) after leaderboard is disposed. + // This is guarded against in BeatmapLeaderboard via web request cancellation, but let's be extra safe. + if (!IsDisposed) + { + // Schedule needs to be non-delayed here for the weird logic in refetchScores to work. + // If it is removed, the placeholder will be incorrectly updated to "no scores" rather than "retrieving". + // This whole flow should be refactored in the future. + Scheduler.Add(applyNewScores, false); + } - Scheduler.Add(updateScoresDrawables, false); + void applyNewScores() + { + userScoreContainer.Score.Value = userScore; + + if (userScore == null) + userScoreContainer.Hide(); + else + userScoreContainer.Show(); + + updateScoresDrawables(); + } } /// @@ -197,8 +210,7 @@ namespace osu.Game.Online.Leaderboards /// /// /// An responsible for the fetch operation. This will be queued and performed automatically. - [CanBeNull] - protected abstract APIRequest FetchScores(CancellationToken cancellationToken); + protected abstract APIRequest? FetchScores(CancellationToken cancellationToken); protected abstract LeaderboardScore CreateDrawableScore(TScoreInfo model, int index); @@ -209,8 +221,8 @@ namespace osu.Game.Online.Leaderboards Debug.Assert(ThreadSafety.IsUpdateThread); cancelPendingWork(); - SetScores(null); + SetScores(null); setState(LeaderboardState.Retrieving); currentFetchCancellationSource = new CancellationTokenSource(); @@ -247,7 +259,7 @@ namespace osu.Game.Online.Leaderboards .Expire(); scoreFlowContainer = null; - if (scores?.Any() != true) + if (!scores.Any()) { setState(LeaderboardState.NoScores); return; @@ -282,7 +294,7 @@ namespace osu.Game.Online.Leaderboards #region Placeholder handling - private Placeholder placeholder; + private Placeholder? placeholder; private void setState(LeaderboardState state) { @@ -309,7 +321,7 @@ namespace osu.Game.Online.Leaderboards placeholder.FadeInFromZero(fade_duration, Easing.OutQuint); } - private Placeholder getPlaceholderFor(LeaderboardState state) + private Placeholder? getPlaceholderFor(LeaderboardState state) { switch (state) { diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 62827f50aa..a7b6bd044d 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -181,7 +181,7 @@ namespace osu.Game.Online.Leaderboards Masking = true, Children = new Drawable[] { - new UpdateableFlag(user.Country) + new UpdateableFlag(user.CountryCode) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, diff --git a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs index 53a4719050..f51f57c031 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs @@ -10,9 +10,13 @@ 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; +using osu.Framework.Bindables; +using osu.Game.Configuration; namespace osu.Game.Online.Leaderboards { @@ -22,6 +26,7 @@ namespace osu.Game.Online.Leaderboards private FillFlowContainer topScoreStatistics = null!; private FillFlowContainer bottomScoreStatistics = null!; private FillFlowContainer modStatistics = null!; + private readonly Bindable prefer24HourTime = new Bindable(); public LeaderboardScoreTooltip() { @@ -34,8 +39,9 @@ namespace osu.Game.Online.Leaderboards } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OsuConfigManager configManager) { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); InternalChildren = new Drawable[] { new Box @@ -90,6 +96,13 @@ namespace osu.Game.Online.Leaderboards }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateTimestampLabel(), true); + } + private ScoreInfo? displayedScore; public void SetContent(ScoreInfo score) @@ -99,7 +112,7 @@ namespace osu.Game.Online.Leaderboards displayedScore = score; - timestampLabel.Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}"; + updateTimestampLabel(); modStatistics.Clear(); topScoreStatistics.Clear(); @@ -119,6 +132,16 @@ namespace osu.Game.Online.Leaderboards } } + private void updateTimestampLabel() + { + if (displayedScore != null) + { + timestampLabel.Text = prefer24HourTime.Value + ? $"Played on {displayedScore.Date.ToLocalTime():d MMMM yyyy HH:mm}" + : $"Played on {displayedScore.Date.ToLocalTime():d MMMM yyyy h:mm tt}"; + } + } + protected override void PopIn() => this.FadeIn(20, Easing.OutQuint); protected override void PopOut() => this.FadeOut(80, Easing.OutQuint); @@ -126,7 +149,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 +157,7 @@ namespace osu.Game.Online.Leaderboards { AutoSizeAxes = Axes.Both; - displayName = stat.DisplayName; + displayName = stat.DisplayName.ToUpper(); result = stat.Result; count = stat.Count; } @@ -153,7 +176,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/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index e4f5f72886..e640fe8494 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -17,7 +17,7 @@ namespace osu.Game.Online.Leaderboards set => Model = value; } - public UpdateableRank(ScoreRank? rank) + public UpdateableRank(ScoreRank? rank = null) { Rank = rank; } diff --git a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs index 2d2d82821c..391e8804f0 100644 --- a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs +++ b/osu.Game/Online/Leaderboards/UserTopScoreContainer.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; using System.Threading; using osu.Framework.Bindables; @@ -18,13 +16,15 @@ namespace osu.Game.Online.Leaderboards { private const int duration = 500; - public Bindable Score = new Bindable(); + public Bindable Score = new Bindable(); private readonly Container scoreContainer; private readonly Func createScoreDelegate; protected override bool StartHidden => true; + private CancellationTokenSource? loadScoreCancellation; + public UserTopScoreContainer(Func createScoreDelegate) { this.createScoreDelegate = createScoreDelegate; @@ -65,9 +65,7 @@ namespace osu.Game.Online.Leaderboards Score.BindValueChanged(onScoreChanged); } - private CancellationTokenSource loadScoreCancellation; - - private void onScoreChanged(ValueChangedEvent score) + private void onScoreChanged(ValueChangedEvent score) { var newScore = score.NewValue; diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 1e5eeb4eb0..60867da2d7 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.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 System; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Graphics; @@ -11,5 +13,13 @@ namespace osu.Game.Online.Metadata public abstract Task BeatmapSetsUpdated(BeatmapUpdates updates); public abstract Task GetChangesSince(int queueId); + + public Action? ChangedBeatmapSetsArrived; + + protected Task ProcessChanges(int[] beatmapSetIDs) + { + ChangedBeatmapSetsArrived?.Invoke(beatmapSetIDs.Distinct().ToArray()); + return Task.CompletedTask; + } } } diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 1b0d1884dc..06d24a82f3 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -1,13 +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 System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; -using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Online.API; @@ -15,7 +15,6 @@ namespace osu.Game.Online.Metadata { public class OnlineMetadataClient : MetadataClient { - private readonly BeatmapUpdater beatmapUpdater; private readonly string endpoint; private IHubClientConnector? connector; @@ -24,9 +23,8 @@ namespace osu.Game.Online.Metadata private HubConnection? connection => connector?.CurrentConnection; - public OnlineMetadataClient(EndpointConfiguration endpoints, BeatmapUpdater beatmapUpdater) + public OnlineMetadataClient(EndpointConfiguration endpoints) { - this.beatmapUpdater = beatmapUpdater; endpoint = endpoints.MetadataEndpointUrl; } @@ -83,6 +81,10 @@ namespace osu.Game.Online.Metadata await ProcessChanges(catchUpChanges.BeatmapSetIDs); } } + catch (Exception e) + { + Logger.Log($"Error while processing catch-up of metadata ({e.Message})"); + } finally { catchingUp = false; @@ -102,17 +104,6 @@ namespace osu.Game.Online.Metadata await ProcessChanges(updates.BeatmapSetIDs); } - protected Task ProcessChanges(int[] beatmapSetIDs) - { - foreach (int id in beatmapSetIDs) - { - Logger.Log($"Processing {id}..."); - beatmapUpdater.Queue(id); - } - - return Task.CompletedTask; - } - public override Task GetChangesSince(int queueId) { if (connector?.IsConnected.Value != true) diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs deleted file mode 100644 index 649e3c8389..0000000000 --- a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs +++ /dev/null @@ -1,20 +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 MessagePack; - -namespace osu.Game.Online.Multiplayer.Countdown -{ - /// - /// Indicates a change to the 's countdown. - /// - [MessagePackObject] - public class CountdownChangedEvent : MatchServerEvent - { - /// - /// The new countdown. - /// - [Key(0)] - public MultiplayerCountdown? Countdown { get; set; } - } -} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs new file mode 100644 index 0000000000..1dbb27bce6 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.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 MessagePack; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates that a countdown started in the . + /// + [MessagePackObject] + public class CountdownStartedEvent : MatchServerEvent + { + /// + /// The countdown that was started. + /// + [Key(0)] + public readonly MultiplayerCountdown Countdown; + + [JsonConstructor] + [SerializationConstructor] + public CountdownStartedEvent(MultiplayerCountdown countdown) + { + Countdown = countdown; + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs new file mode 100644 index 0000000000..b46ed0e5e0 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.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 MessagePack; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates that a countdown was stopped in the . + /// + [MessagePackObject] + public class CountdownStoppedEvent : MatchServerEvent + { + /// + /// The identifier of the countdown that was stopped. + /// + [Key(0)] + public readonly int ID; + + [JsonConstructor] + [SerializationConstructor] + public CountdownStoppedEvent(int id) + { + ID = id; + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs index bd0c381c0b..495252c044 100644 --- a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs +++ b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using MessagePack; +using Newtonsoft.Json; namespace osu.Game.Online.Multiplayer.Countdown { @@ -11,5 +12,14 @@ namespace osu.Game.Online.Multiplayer.Countdown [MessagePackObject] public class StopCountdownRequest : MatchUserRequest { + [Key(0)] + public readonly int ID; + + [JsonConstructor] + [SerializationConstructor] + public StopCountdownRequest(int id) + { + ID = id; + } } } diff --git a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs index 7f5c0f0a05..bbfc5a02c6 100644 --- a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs @@ -13,7 +13,7 @@ namespace osu.Game.Online.Multiplayer /// and forcing progression of any clients that are blocking load due to user interaction. /// [MessagePackObject] - public class ForceGameplayStartCountdown : MultiplayerCountdown + public sealed class ForceGameplayStartCountdown : MultiplayerCountdown { } } diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 20bf9e5141..376ff4d261 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -13,7 +13,8 @@ namespace osu.Game.Online.Multiplayer [Serializable] [MessagePackObject] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. - [Union(0, typeof(CountdownChangedEvent))] + [Union(0, typeof(CountdownStartedEvent))] + [Union(1, typeof(CountdownStoppedEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs index 5d3365c947..fe65ebb059 100644 --- a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.Multiplayer /// A which will start the match after ending. /// [MessagePackObject] - public class MatchStartCountdown : MultiplayerCountdown + public sealed class MatchStartCountdown : MultiplayerCountdown { } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 9832acb140..75334952f0 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -18,15 +18,17 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Utils; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Online.Multiplayer { public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer { + public Action? PostNotification { protected get; set; } + /// /// Invoked when any change occurs to the multiplayer room. /// @@ -91,7 +93,7 @@ namespace osu.Game.Online.Multiplayer /// /// The joined . /// - public virtual MultiplayerRoom? Room + public virtual MultiplayerRoom? Room // virtual for moq { get { @@ -150,7 +152,7 @@ namespace osu.Game.Online.Multiplayer // clean up local room state on server disconnect. if (!connected.NewValue && Room != null) { - Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); + Logger.Log("Clearing room due to multiplayer server connection loss.", LoggingTarget.Runtime, LogLevel.Important); LeaveRoom(); } })); @@ -197,6 +199,9 @@ namespace osu.Game.Online.Multiplayer APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); + // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. + APIRoom.EndDate.Value = null; + Debug.Assert(LocalUser != null); addUserToAPIRoom(LocalUser); @@ -205,6 +210,8 @@ namespace osu.Game.Online.Multiplayer updateLocalRoomSettings(joinedRoom.Settings); + postServerShuttingDownNotification(); + OnRoomJoined(); }, cancellationSource.Token).ConfigureAwait(false); }, cancellationSource.Token).ConfigureAwait(false); @@ -263,8 +270,9 @@ namespace osu.Game.Online.Multiplayer /// The type of the match, if any. /// The new queue mode, if any. /// The new auto-start countdown duration, if any. + /// The new auto-skip setting. public Task ChangeSettings(Optional name = default, Optional password = default, Optional matchType = default, Optional queueMode = default, - Optional autoStartDuration = default) + Optional autoStartDuration = default, Optional autoSkip = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -276,6 +284,7 @@ namespace osu.Game.Online.Multiplayer MatchType = matchType.GetOr(Room.Settings.MatchType), QueueMode = queueMode.GetOr(Room.Settings.QueueMode), AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration), + AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip) }); } @@ -548,8 +557,22 @@ namespace osu.Game.Online.Multiplayer switch (e) { - case CountdownChangedEvent countdownChangedEvent: - Room.Countdown = countdownChangedEvent.Countdown; + case CountdownStartedEvent countdownStartedEvent: + Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown); + + switch (countdownStartedEvent.Countdown) + { + case ServerShuttingDownCountdown: + postServerShuttingDownNotification(); + break; + } + + break; + + case CountdownStoppedEvent countdownStoppedEvent: + MultiplayerCountdown? countdown = Room.ActiveCountdowns.FirstOrDefault(countdown => countdown.ID == countdownStoppedEvent.ID); + if (countdown != null) + Room.ActiveCountdowns.Remove(countdown); break; } @@ -559,6 +582,16 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + private void postServerShuttingDownNotification() + { + ServerShuttingDownCountdown? countdown = room?.ActiveCountdowns.OfType().FirstOrDefault(); + + if (countdown == null) + return; + + PostNotification?.Invoke(new ServerShutdownNotification(countdown.TimeRemaining)); + } + Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { Scheduler.Add(() => @@ -737,6 +770,7 @@ namespace osu.Game.Online.Multiplayer APIRoom.QueueMode.Value = Room.Settings.QueueMode; APIRoom.AutoStartDuration.Value = Room.Settings.AutoStartDuration; APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); + APIRoom.AutoSkip.Value = Room.Settings.AutoSkip; RoomUpdated?.Invoke(); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs index 621f9236fd..c59f5937b0 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs @@ -13,15 +13,28 @@ namespace osu.Game.Online.Multiplayer [MessagePackObject] [Union(0, typeof(MatchStartCountdown))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(1, typeof(ForceGameplayStartCountdown))] + [Union(2, typeof(ServerShuttingDownCountdown))] public abstract class MultiplayerCountdown { + /// + /// A unique identifier for this countdown. + /// + [Key(0)] + public int ID { get; set; } + /// /// The amount of time remaining in the countdown. /// /// - /// This is only sent once from the server upon initial retrieval of the or via a . + /// This is only sent once from the server upon initial retrieval of the or via a . /// - [Key(0)] + [Key(1)] public TimeSpan TimeRemaining { get; set; } + + /// + /// Whether only a single instance of this type may be active at any one time. + /// + [IgnoreMember] + public virtual bool IsExclusive => true; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index fb05c03256..00048fa931 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -53,10 +53,10 @@ namespace osu.Game.Online.Multiplayer public IList Playlist { get; set; } = new List(); /// - /// The currently-running countdown. + /// The currently running countdowns. /// [Key(7)] - public MultiplayerCountdown? Countdown { get; set; } + public IList ActiveCountdowns { get; set; } = new List(); [JsonConstructor] [SerializationConstructor] diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index 356acb427c..c73b02874e 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -29,6 +29,9 @@ namespace osu.Game.Online.Multiplayer [Key(5)] public TimeSpan AutoStartDuration { get; set; } + [Key(6)] + public bool AutoSkip { get; set; } + [IgnoreMember] public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; @@ -42,7 +45,8 @@ namespace osu.Game.Online.Multiplayer && PlaylistItemId == other.PlaylistItemId && MatchType == other.MatchType && QueueMode == other.QueueMode - && AutoStartDuration == other.AutoStartDuration; + && AutoStartDuration == other.AutoStartDuration + && AutoSkip == other.AutoSkip; } public override string ToString() => $"Name:{Name}" @@ -50,6 +54,7 @@ namespace osu.Game.Online.Multiplayer + $" Type:{MatchType}" + $" Item:{PlaylistItemId}" + $" Queue:{QueueMode}" - + $" Start:{AutoStartDuration}"; + + $" Start:{AutoStartDuration}" + + $" AutoSkip:{AutoSkip}"; } } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index c061398209..190d150502 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -85,7 +85,13 @@ namespace osu.Game.Online.Multiplayer catch (HubException exception) { if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) - connector?.Reconnect(); + { + Debug.Assert(connector != null); + + await connector.Reconnect(); + return await JoinRoom(roomId, password); + } + throw; } } diff --git a/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs b/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs new file mode 100644 index 0000000000..c114741be8 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs @@ -0,0 +1,62 @@ +// 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 Humanizer.Localisation; +using osu.Framework.Allocation; +using osu.Framework.Threading; +using osu.Game.Overlays.Notifications; +using osu.Game.Utils; + +namespace osu.Game.Online.Multiplayer +{ + public class ServerShutdownNotification : SimpleNotification + { + private readonly DateTimeOffset endDate; + private ScheduledDelegate? updateDelegate; + + public ServerShutdownNotification(TimeSpan duration) + { + endDate = DateTimeOffset.UtcNow + duration; + } + + [BackgroundDependencyLoader] + private void load() + { + updateTime(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDelegate = Scheduler.Add(updateTimeWithReschedule); + } + + private void updateTimeWithReschedule() + { + updateTime(); + + // The remaining time on a countdown may be at a fractional portion between two seconds. + // We want to align certain audio/visual cues to the point at which integer seconds change. + // To do so, we schedule to the next whole second. Note that scheduler invocation isn't + // guaranteed to be accurate, so this may still occur slightly late, but even in such a case + // the next invocation will be roughly correct. + double timeToNextSecond = endDate.Subtract(DateTimeOffset.UtcNow).TotalMilliseconds % 1000; + + updateDelegate = Scheduler.AddDelayed(updateTimeWithReschedule, timeToNextSecond); + } + + private void updateTime() + { + TimeSpan remaining = endDate.Subtract(DateTimeOffset.Now); + + if (remaining.TotalSeconds <= 5) + { + updateDelegate?.Cancel(); + Text = "The multiplayer server will be right back..."; + } + else + Text = $"The multiplayer server is restarting in {HumanizerUtils.Humanize(remaining, precision: 3, minUnit: TimeUnit.Second)}."; + } + } +} diff --git a/osu.Game/Online/Multiplayer/ServerShuttingDownCountdown.cs b/osu.Game/Online/Multiplayer/ServerShuttingDownCountdown.cs new file mode 100644 index 0000000000..b0a45dc768 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ServerShuttingDownCountdown.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A countdown that indicates the current multiplayer server is shutting down. + /// + [MessagePackObject] + public class ServerShuttingDownCountdown : MultiplayerCountdown + { + } +} 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/GetRoomsRequest.cs b/osu.Game/Online/Rooms/GetRoomsRequest.cs index 5ae9d58189..afab83b5be 100644 --- a/osu.Game/Online/Rooms/GetRoomsRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomsRequest.cs @@ -4,8 +4,8 @@ #nullable disable using System.Collections.Generic; -using Humanizer; using osu.Framework.IO.Network; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Screens.OnlinePlay.Lounge.Components; @@ -27,7 +27,7 @@ namespace osu.Game.Online.Rooms var req = base.CreateWebRequest(); if (status != RoomStatusFilter.Open) - req.AddParameter("mode", status.ToString().Underscore().ToLowerInvariant()); + req.AddParameter("mode", status.ToString().ToSnakeCase().ToLowerInvariant()); if (!string.IsNullOrEmpty(category)) req.AddParameter("category", category); diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 6f597e5b10..d5e0c7a970 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -65,7 +65,7 @@ namespace osu.Game.Online.Rooms [CanBeNull] public MultiplayerScoresAround ScoresAround { get; set; } - public ScoreInfo CreateScoreInfo(RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) + public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) { var ruleset = rulesets.GetRuleset(playlistItem.RulesetID); if (ruleset == null) @@ -90,6 +90,8 @@ namespace osu.Game.Online.Rooms Position = Position, }; + scoreManager.PopulateMaximumStatistics(scoreInfo); + return scoreInfo; } } diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 2fd8445980..7f8f9703e4 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!; @@ -117,6 +114,7 @@ namespace osu.Game.Online.Rooms switch (downloadTracker.State.Value) { + case DownloadState.Unknown: case DownloadState.NotDownloaded: availability.Value = BeatmapAvailability.NotDownloaded(); break; diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 3a24fa02a8..adfd4c226a 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -159,6 +159,10 @@ namespace osu.Game.Online.Rooms set => MaxAttempts.Value = value; } + [Cached] + [JsonProperty("auto_skip")] + public readonly Bindable AutoSkip = new Bindable(); + public Room() { Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); @@ -195,6 +199,7 @@ namespace osu.Game.Online.Rooms DifficultyRange.Value = other.DifficultyRange.Value; PlaylistItemStats.Value = other.PlaylistItemStats.Value; CurrentPlaylistItem.Value = other.CurrentPlaylistItem.Value; + AutoSkip.Value = other.AutoSkip.Value; if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); diff --git a/osu.Game/Online/Rooms/SubmitScoreRequest.cs b/osu.Game/Online/Rooms/SubmitScoreRequest.cs index d681938da5..48a7780a03 100644 --- a/osu.Game/Online/Rooms/SubmitScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitScoreRequest.cs @@ -7,20 +7,20 @@ using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Game.Online.API; -using osu.Game.Online.Solo; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; namespace osu.Game.Online.Rooms { public abstract class SubmitScoreRequest : APIRequest { - public readonly SubmittableScore Score; + public readonly SoloScoreInfo Score; protected readonly long ScoreId; protected SubmitScoreRequest(ScoreInfo scoreInfo, long scoreId) { - Score = new SubmittableScore(scoreInfo); + Score = SoloScoreInfo.ForSubmission(scoreInfo); ScoreId = scoreId; } diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index 29e01e13ae..0b545821ee 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -23,11 +23,13 @@ namespace osu.Game.Online (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), (typeof(StopCountdownRequest), typeof(MatchUserRequest)), - (typeof(CountdownChangedEvent), typeof(MatchServerEvent)), + (typeof(CountdownStartedEvent), typeof(MatchServerEvent)), + (typeof(CountdownStoppedEvent), typeof(MatchServerEvent)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)), - (typeof(ForceGameplayStartCountdown), typeof(MultiplayerCountdown)) + (typeof(ForceGameplayStartCountdown), typeof(MultiplayerCountdown)), + (typeof(ServerShuttingDownCountdown), typeof(MultiplayerCountdown)), }; } } diff --git a/osu.Game/Online/Solo/SubmittableScore.cs b/osu.Game/Online/Solo/SubmittableScore.cs deleted file mode 100644 index 31f0cb1dfb..0000000000 --- a/osu.Game/Online/Solo/SubmittableScore.cs +++ /dev/null @@ -1,72 +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 System.Collections.Generic; -using JetBrains.Annotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; - -namespace osu.Game.Online.Solo -{ - /// - /// A class specifically for sending scores to the API during score submission. - /// This is used instead of due to marginally different serialisation naming requirements. - /// - [Serializable] - public class SubmittableScore - { - [JsonProperty("rank")] - [JsonConverter(typeof(StringEnumConverter))] - public ScoreRank Rank { get; set; } - - [JsonProperty("total_score")] - public long TotalScore { get; set; } - - [JsonProperty("accuracy")] - public double Accuracy { get; set; } - - [JsonProperty(@"pp")] - public double? PP { get; set; } - - [JsonProperty("max_combo")] - public int MaxCombo { get; set; } - - [JsonProperty("ruleset_id")] - public int RulesetID { get; set; } - - [JsonProperty("passed")] - public bool Passed { get; set; } - - // Used for API serialisation/deserialisation. - [JsonProperty("mods")] - public APIMod[] Mods { get; set; } - - [JsonProperty("statistics")] - public Dictionary Statistics { get; set; } - - [UsedImplicitly] - public SubmittableScore() - { - } - - public SubmittableScore(ScoreInfo score) - { - Rank = score.Rank; - TotalScore = score.TotalScore; - Accuracy = score.Accuracy; - PP = score.PP; - MaxCombo = score.MaxCombo; - RulesetID = score.RulesetID; - Passed = score.Passed; - Mods = score.APIMods; - Statistics = score.Statistics; - } - } -} diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index cd9f5233a2..48d5c0bea9 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -1,9 +1,9 @@ // 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.Diagnostics; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -56,13 +56,20 @@ namespace osu.Game.Online.Spectator try { - await connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), state); + await connection.InvokeAsync(nameof(ISpectatorServer.BeginPlaySession), state); } - catch (HubException exception) + catch (Exception exception) { if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) - connector?.Reconnect(); - throw; + { + Debug.Assert(connector != null); + + await connector.Reconnect(); + await BeginPlayingInternal(state); + } + + // Exceptions can occur if, for instance, the locally played beatmap doesn't have a server-side counterpart. + // For now, let's ignore these so they don't cause unobserved exceptions to appear to the user (and sentry). } } @@ -83,7 +90,7 @@ namespace osu.Game.Online.Spectator Debug.Assert(connection != null); - return connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state); + return connection.InvokeAsync(nameof(ISpectatorServer.EndPlaySession), state); } protected override Task WatchUserInternal(int userId) @@ -93,7 +100,7 @@ namespace osu.Game.Online.Spectator Debug.Assert(connection != null); - return connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); + return connection.InvokeAsync(nameof(ISpectatorServer.StartWatchingUser), userId); } protected override Task StopWatchingUserInternal(int userId) @@ -103,7 +110,7 @@ namespace osu.Game.Online.Spectator Debug.Assert(connection != null); - return connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); + return connection.InvokeAsync(nameof(ISpectatorServer.EndWatchingUser), userId); } } } diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 68c8b57019..592bae80ba 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -65,11 +65,12 @@ namespace osu.Game.Online.Spectator public virtual event Action? OnUserFinishedPlaying; /// - /// All users currently being watched. + /// A dictionary containing all users currently being watched, with the number of watching components for each user. /// - private readonly List watchedUsers = new List(); + private readonly Dictionary watchedUsersRefCounts = new Dictionary(); private readonly BindableDictionary watchedUserStates = new BindableDictionary(); + private readonly BindableList playingUsers = new BindableList(); private readonly SpectatorState currentState = new SpectatorState(); @@ -94,12 +95,15 @@ namespace osu.Game.Online.Spectator if (connected.NewValue) { // get all the users that were previously being watched - int[] users = watchedUsers.ToArray(); - watchedUsers.Clear(); + var users = new Dictionary(watchedUsersRefCounts); + watchedUsersRefCounts.Clear(); // resubscribe to watched users. - foreach (int userId in users) - WatchUser(userId); + foreach ((int user, int watchers) in users) + { + for (int i = 0; i < watchers; i++) + WatchUser(user); + } // re-send state in case it wasn't received if (IsPlaying) @@ -121,7 +125,7 @@ namespace osu.Game.Online.Spectator if (!playingUsers.Contains(userId)) playingUsers.Add(userId); - if (watchedUsers.Contains(userId)) + if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; OnUserBeganPlaying?.Invoke(userId, state); @@ -136,7 +140,7 @@ namespace osu.Game.Online.Spectator { playingUsers.Remove(userId); - if (watchedUsers.Contains(userId)) + if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; OnUserFinishedPlaying?.Invoke(userId, state); @@ -206,6 +210,11 @@ namespace osu.Game.Online.Spectator if (!IsPlaying) return; + // Disposal can take some time, leading to EndPlaying potentially being called after a future play session. + // Account for this by ensuring the score of the current play matches the one in the provided state. + if (currentScore != state.Score) + return; + if (pendingFrames.Count > 0) purgePendingFrames(); @@ -227,11 +236,13 @@ namespace osu.Game.Online.Spectator { Debug.Assert(ThreadSafety.IsUpdateThread); - if (watchedUsers.Contains(userId)) + if (watchedUsersRefCounts.ContainsKey(userId)) + { + watchedUsersRefCounts[userId]++; return; + } - watchedUsers.Add(userId); - + watchedUsersRefCounts.Add(userId, 1); WatchUserInternal(userId); } @@ -241,7 +252,13 @@ namespace osu.Game.Online.Spectator // Todo: This should not be a thing, but requires framework changes. Schedule(() => { - watchedUsers.Remove(userId); + if (watchedUsersRefCounts.TryGetValue(userId, out int watchers) && watchers > 1) + { + watchedUsersRefCounts[userId]--; + return; + } + + watchedUsersRefCounts.Remove(userId); watchedUserStates.Remove(userId); StopWatchingUserInternal(userId); }); @@ -299,7 +316,7 @@ namespace osu.Game.Online.Spectator SendFramesInternal(bundle).ContinueWith(t => { - // Handle exception outside of `Schedule` to ensure it doesn't go unovserved. + // Handle exception outside of `Schedule` to ensure it doesn't go unobserved. bool wasSuccessful = t.Exception == null; return Schedule(() => diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index bd0a2680ae..2bdcb57f2a 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -24,6 +24,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Screens; @@ -56,14 +57,12 @@ using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; -using osu.Game.Skinning; using osu.Game.Skinning.Editor; using osu.Game.Updater; using osu.Game.Users; using osu.Game.Utils; using osuTK.Graphics; using Sentry; -using Logger = osu.Framework.Logging.Logger; namespace osu.Game { @@ -71,6 +70,7 @@ namespace osu.Game /// The full osu! experience. Builds on top of to add menus and binding logic /// for initial components that are generally retrieved via DI. /// + [Cached(typeof(OsuGame))] public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager, ILinkHandler { /// @@ -137,6 +137,11 @@ namespace osu.Game private IdleTracker idleTracker; + /// + /// Whether the user is currently in an idle state. + /// + public IBindable IsIdle => idleTracker.IsIdle; + /// /// Whether overlays should be able to be opened game-wide. Value is sourced from the current active screen. /// @@ -159,6 +164,8 @@ namespace osu.Game protected FirstRunSetupOverlay FirstRunOverlay { get; private set; } + private FPSCounter fpsCounter; + private VolumeOverlay volume; private OsuLogo osuLogo; @@ -187,7 +194,8 @@ namespace osu.Game { this.args = args; - forwardLoggedErrorsToNotifications(); + forwardGeneralLogsToNotifications(); + forwardTabletLogsToNotifications(); SentryLogger = new SentryLogger(this); } @@ -264,8 +272,6 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - dependencies.CacheAs(this); - SentryLogger.AttachUser(API.LocalUser); dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); @@ -292,25 +298,13 @@ namespace osu.Game Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName; - // bind config int to database SkinInfo configSkin = LocalConfig.GetBindable(OsuSetting.Skin); + + // Transfer skin from config to realm instance once on startup. + SkinManager.SetSkinFromConfiguration(configSkin.Value); + + // Transfer any runtime changes back to configuration file. SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); - configSkin.ValueChanged += skinId => - { - Live skinInfo = null; - - if (Guid.TryParse(skinId.NewValue, out var guid)) - skinInfo = SkinManager.Query(s => s.ID == guid); - - if (skinInfo == null) - { - if (guid == SkinInfo.CLASSIC_SKIN) - skinInfo = DefaultLegacySkin.CreateInfo().ToLiveUnmanaged(); - } - - SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLiveUnmanaged(); - }; - configSkin.TriggerChange(); IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true); @@ -571,9 +565,20 @@ namespace osu.Game return; } + // This should be able to be performed from song select, but that is disabled for now + // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). + // + // As a special case, if the beatmap and ruleset already match, allow immediately displaying the score from song select. + // This is guaranteed to not crash, and feels better from a user's perspective (ie. if they are clicking a score in the + // song select leaderboard). + IEnumerable validScreens = + Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset) + ? new[] { typeof(SongSelect) } + : Array.Empty(); + PerformFromScreen(screen => { - Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset} to match score"); + Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset}) to match score"); Ruleset.Value = databasedScore.ScoreInfo.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); @@ -588,7 +593,7 @@ namespace osu.Game screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo, false)); break; } - }, validScreens: new[] { typeof(PlaySongSelect) }); + }, validScreens: validScreens); } public override Task Import(params ImportTask[] imports) @@ -714,7 +719,7 @@ namespace osu.Game // The next time this is updated is in UpdateAfterChildren, which occurs too late and results // in the cursor being shown for a few frames during the intro. // This prevents the cursor from showing until we have a screen with CursorVisible = true - MenuCursorContainer.CanShowCursor = menuScreen?.CursorVisible ?? false; + GlobalCursorDisplay.ShowCursor = menuScreen?.CursorVisible ?? false; // todo: all archive managers should be able to be looped here. SkinManager.PostNotification = n => Notifications.Post(n); @@ -728,6 +733,8 @@ namespace osu.Game ScoreManager.PostNotification = n => Notifications.Post(n); ScoreManager.PresentImport = items => PresentScore(items.First().Value); + MultiplayerClient.PostNotification = n => Notifications.Post(n); + // make config aware of how to lookup skins for on-screen display purposes. // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; @@ -802,8 +809,8 @@ namespace osu.Game Children = new Drawable[] { overlayContent = new Container { RelativeSizeAxes = Axes.Both }, - rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, + rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, } }, topMostOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, @@ -814,6 +821,13 @@ namespace osu.Game ScreenStack.ScreenPushed += screenPushed; ScreenStack.ScreenExited += screenExited; + loadComponentSingleFile(fpsCounter = new FPSCounter + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(5), + }, topMostOverlayContent.Add); + if (!args?.Any(a => a == @"--no-version-overlay") ?? true) loadComponentSingleFile(versionManager = new VersionManager { Depth = int.MinValue }, ScreenContainer.Add); @@ -830,7 +844,9 @@ namespace osu.Game OnHome = delegate { CloseAllOverlays(false); - menuScreen?.MakeCurrent(); + + if (menuScreen?.GetChildScreen() != null) + menuScreen.MakeCurrent(); }, }, topMostOverlayContent.Add); @@ -849,11 +865,6 @@ namespace osu.Game d.Origin = Anchor.TopRight; }), rightFloatingOverlayContent.Add, true); - loadComponentSingleFile(new CollectionManager(Storage) - { - PostNotification = n => Notifications.Post(n), - }, Add, true); - loadComponentSingleFile(legacyImportManager, Add); loadComponentSingleFile(screenshotManager, Add); @@ -895,6 +906,8 @@ namespace osu.Game loadComponentSingleFile(CreateHighPerformanceSession(), Add); + loadComponentSingleFile(new BackgroundBeatmapProcessor(), Add); + chatOverlay.State.BindValueChanged(_ => updateChatPollRate()); // Multiplayer modes need to increase poll rate temporarily. API.Activity.BindValueChanged(_ => updateChatPollRate(), true); @@ -998,7 +1011,7 @@ namespace osu.Game overlay.Depth = (float)-Clock.CurrentTime; } - private void forwardLoggedErrorsToNotifications() + private void forwardGeneralLogsToNotifications() { int recentLogCount = 0; @@ -1006,7 +1019,7 @@ namespace osu.Game Logger.NewEntry += entry => { - if (entry.Level < LogLevel.Important || entry.Target == null) return; + if (entry.Level < LogLevel.Important || entry.Target > LoggingTarget.Database) return; const int short_term_display_limit = 3; @@ -1039,6 +1052,52 @@ namespace osu.Game }; } + private void forwardTabletLogsToNotifications() + { + const string tablet_prefix = @"[Tablet] "; + bool notifyOnWarning = true; + + Logger.NewEntry += entry => + { + if (entry.Level < LogLevel.Important || entry.Target != LoggingTarget.Input || !entry.Message.StartsWith(tablet_prefix, StringComparison.OrdinalIgnoreCase)) + return; + + string message = entry.Message.Replace(tablet_prefix, string.Empty); + + if (entry.Level == LogLevel.Error) + { + Schedule(() => Notifications.Post(new SimpleNotification + { + Text = $"Encountered tablet error: \"{message}\"", + Icon = FontAwesome.Solid.PenSquare, + IconColour = Colours.RedDark, + })); + } + else if (notifyOnWarning) + { + Schedule(() => Notifications.Post(new SimpleNotification + { + Text = @"Encountered tablet warning, your tablet may not function correctly. Click here for a list of all tablets supported.", + Icon = FontAwesome.Solid.PenSquare, + IconColour = Colours.YellowDark, + Activated = () => + { + OpenUrlExternally("https://opentabletdriver.net/Tablets", true); + return true; + } + })); + + notifyOnWarning = false; + } + }; + + Schedule(() => + { + ITabletHandler tablet = Host.AvailableInputHandlers.OfType().SingleOrDefault(); + tablet?.Tablet.BindValueChanged(_ => notifyOnWarning = true, true); + }); + } + private Task asyncLoadStream; /// @@ -1114,6 +1173,10 @@ namespace osu.Game switch (e.Action) { + case GlobalAction.ToggleFPSDisplay: + fpsCounter.ToggleVisibility(); + return true; + case GlobalAction.ToggleSkinEditor: skinEditor.ToggleVisibility(); return true; @@ -1128,6 +1191,13 @@ namespace osu.Game mouseDisableButtons.Value = !mouseDisableButtons.Value; return true; + case GlobalAction.ToggleProfile: + if (userProfile.State.Value == Visibility.Visible) + userProfile.Hide(); + else + ShowUser(API.LocalUser.Value); + return true; + case GlobalAction.RandomSkin: // Don't allow random skin selection while in the skin editor. // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path. @@ -1216,7 +1286,7 @@ namespace osu.Game ScreenOffsetContainer.X = horizontalOffset; overlayContent.X = horizontalOffset * 1.2f; - MenuCursorContainer.CanShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; + GlobalCursorDisplay.ShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } private void screenChanged(IScreen current, IScreen newScreen) @@ -1263,6 +1333,8 @@ namespace osu.Game OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); API.Activity.BindTo(newOsuScreen.Activity); + GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newOsuScreen.HideMenuCursorOnNonMouseInput; + if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); else diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index a16252ac89..7d9ed7bf3e 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Threading; @@ -17,7 +18,6 @@ using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Input.Handlers; @@ -62,6 +62,7 @@ namespace osu.Game /// Unlike , this class will not load any kind of UI, allowing it to be used /// for provide dependencies to test cases without interfering with them. /// + [Cached(typeof(OsuGameBase))] public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv" }; @@ -125,6 +126,8 @@ namespace osu.Game protected SessionStatics SessionStatics { get; private set; } + protected OsuColour Colours { get; private set; } + protected BeatmapManager BeatmapManager { get; private set; } protected BeatmapModelDownloader BeatmapDownloader { get; private set; } @@ -139,7 +142,7 @@ namespace osu.Game protected RealmKeyBindingStore KeyBindingStore { get; private set; } - protected MenuCursorContainer MenuCursorContainer { get; private set; } + protected GlobalCursorDisplay GlobalCursorDisplay { get; private set; } protected MusicController MusicController { get; private set; } @@ -180,27 +183,26 @@ namespace osu.Game private SpectatorClient spectatorClient; - private MultiplayerClient multiplayerClient; + protected MultiplayerClient MultiplayerClient { get; private set; } private MetadataClient metadataClient; private RealmAccess realm; + /// + /// For now, this is used as a source specifically for beat synced components. + /// Going forward, it could potentially be used as the single source-of-truth for beatmap timing. + /// + private readonly FramedBeatmapClock beatmapClock = new FramedBeatmapClock(true); + protected override Container Content => content; private Container content; private DependencyContainer dependencies; - private Bindable fpsDisplayVisible; - private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(global_track_volume_adjust); - /// - /// A legacy EF context factory if migration has not been performed to realm yet. - /// - protected DatabaseContextFactory EFContextFactory { get; private set; } - /// /// Number of unhandled exceptions to allow before aborting execution. /// @@ -215,6 +217,10 @@ namespace osu.Game { Name = @"osu!"; +#if DEBUG + Name += " (development)"; +#endif + allowableExceptions = UnhandledExceptionsBeforeCrash; } @@ -235,10 +241,7 @@ namespace osu.Game Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); - if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME)) - dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage)); - - dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread, EFContextFactory)); + dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread)); dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage)); dependencies.CacheAs(RulesetStore); @@ -247,15 +250,16 @@ namespace osu.Game dependencies.CacheAs(Storage); - var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); + var largeStore = new LargeTextureStore(Host.Renderer, Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); largeStore.AddTextureSource(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); - dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); InitialiseFonts(); + addFilesWarning(); + Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; dependencies.Cache(SkinManager = new SkinManager(Storage, realm, Host, Resources, Audio, Scheduler)); @@ -272,9 +276,9 @@ namespace osu.Game dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() - dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, Scheduler, difficultyCache, LocalConfig)); + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig)); - dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); + dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); @@ -283,13 +287,14 @@ namespace osu.Game AddInternal(difficultyCache); // TODO: OsuGame or OsuGameBase? - beatmapUpdater = new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage); - + dependencies.CacheAs(beatmapUpdater = new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage)); dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); - dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); - dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints, beatmapUpdater)); + dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints)); + dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints)); - BeatmapManager.ProcessBeatmap = set => beatmapUpdater.Process(set); + AddInternal(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient)); + + BeatmapManager.ProcessBeatmap = args => beatmapUpdater.Process(args.beatmapSet, !args.isBatch); dependencies.Cache(userCache = new UserLookupCache()); AddInternal(userCache); @@ -308,7 +313,7 @@ namespace osu.Game dependencies.CacheAs(powerStatus); dependencies.Cache(SessionStatics = new SessionStatics()); - dependencies.Cache(new OsuColour()); + dependencies.Cache(Colours = new OsuColour()); RegisterImportHandler(BeatmapManager); RegisterImportHandler(ScoreManager); @@ -329,7 +334,7 @@ namespace osu.Game AddInternal(apiAccess); AddInternal(spectatorClient); - AddInternal(multiplayerClient); + AddInternal(MultiplayerClient); AddInternal(metadataClient); AddInternal(rulesetConfigCache); @@ -342,10 +347,10 @@ namespace osu.Game RelativeSizeAxes = Axes.Both, Child = CreateScalingContainer().WithChildren(new Drawable[] { - (MenuCursorContainer = new MenuCursorContainer + (GlobalCursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both - }).WithChild(content = new OsuTooltipContainer(MenuCursorContainer.Cursor) + }).WithChild(content = new OsuTooltipContainer(GlobalCursorDisplay.MenuCursor) { RelativeSizeAxes = Axes.Both }), @@ -366,10 +371,47 @@ namespace osu.Game AddInternal(MusicController = new MusicController()); dependencies.CacheAs(MusicController); + MusicController.TrackChanged += onTrackChanged; + AddInternal(beatmapClock); + Ruleset.BindValueChanged(onRulesetChanged); Beatmap.BindValueChanged(onBeatmapChanged); } + private void addFilesWarning() + { + var realmStore = new RealmFileStore(realm, Storage); + + const string filename = "IMPORTANT READ ME.txt"; + + if (!realmStore.Storage.Exists(filename)) + { + using (var stream = realmStore.Storage.CreateFileSafely(filename)) + using (var textWriter = new StreamWriter(stream)) + { + textWriter.WriteLine(@"This folder contains all your user files (beatmaps, skins, replays etc.)"); + textWriter.WriteLine(@"Please do not touch or delete this folder!!"); + textWriter.WriteLine(); + textWriter.WriteLine(@"If you are really looking to completely delete user data, please delete"); + textWriter.WriteLine(@"the parent folder including all other files and directories"); + textWriter.WriteLine(); + textWriter.WriteLine(@"For more information on how these files are organised,"); + textWriter.WriteLine(@"see https://github.com/ppy/osu/wiki/User-file-storage"); + } + } + } + + private void onTrackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction) + { + // FramedBeatmapClock uses a decoupled clock internally which will mutate the source if it is an `IAdjustableClock`. + // We don't want this for now, as the intention of beatmapClock is to be a read-only source for beat sync components. + // + // Encapsulating in a FramedClock will avoid any mutations. + var framedClock = new FramedClock(beatmap.Track); + + beatmapClock.ChangeSource(framedClock); + } + protected virtual void InitialiseFonts() { AddFont(Resources, @"Fonts/osuFont"); @@ -404,19 +446,6 @@ namespace osu.Game AddFont(Resources, @"Fonts/Venera/Venera-Black"); } - protected override void LoadComplete() - { - base.LoadComplete(); - - // TODO: This is temporary until we reimplement the local FPS display. - // It's just to allow end-users to access the framework FPS display without knowing the shortcut key. - fpsDisplayVisible = LocalConfig.GetBindable(OsuSetting.ShowFpsDisplay); - fpsDisplayVisible.ValueChanged += visible => { FrameStatistics.Value = visible.NewValue ? FrameStatisticsMode.Minimal : FrameStatisticsMode.None; }; - fpsDisplayVisible.TriggerChange(); - - FrameStatistics.ValueChanged += e => fpsDisplayVisible.Value = e.NewValue != FrameStatisticsMode.None; - } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -598,7 +627,7 @@ namespace osu.Game } ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.BeatmapLoaded ? Beatmap.Value.Beatmap.ControlPointInfo : null; - IClock IBeatSyncProvider.Clock => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track : (IClock)null; - ChannelAmplitudes? IBeatSyncProvider.Amplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : null; + IClock IBeatSyncProvider.Clock => beatmapClock; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 767b8646e3..ff9a46ed4f 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -143,7 +143,7 @@ namespace osu.Game.Overlays.BeatmapListing } public void Search(string query) - => searchControl.Query.Value = query; + => Schedule(() => searchControl.Query.Value = query); protected override void LoadComplete() { @@ -151,19 +151,20 @@ namespace osu.Game.Overlays.BeatmapListing config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize); - var sortCriteria = sortControl.Current; - var sortDirection = sortControl.SortDirection; - - searchControl.Query.BindValueChanged(query => + searchControl.Query.BindValueChanged(_ => { - sortCriteria.Value = string.IsNullOrEmpty(query.NewValue) ? SortCriteria.Ranked : SortCriteria.Relevance; - sortDirection.Value = SortDirection.Descending; + resetSortControl(); queueUpdateSearch(true); }); + searchControl.Category.BindValueChanged(_ => + { + resetSortControl(); + queueUpdateSearch(); + }); + searchControl.General.CollectionChanged += (_, _) => queueUpdateSearch(); searchControl.Ruleset.BindValueChanged(_ => queueUpdateSearch()); - searchControl.Category.BindValueChanged(_ => queueUpdateSearch()); searchControl.Genre.BindValueChanged(_ => queueUpdateSearch()); searchControl.Language.BindValueChanged(_ => queueUpdateSearch()); searchControl.Extra.CollectionChanged += (_, _) => queueUpdateSearch(); @@ -171,8 +172,8 @@ namespace osu.Game.Overlays.BeatmapListing searchControl.Played.BindValueChanged(_ => queueUpdateSearch()); searchControl.ExplicitContent.BindValueChanged(_ => queueUpdateSearch()); - sortCriteria.BindValueChanged(_ => queueUpdateSearch()); - sortDirection.BindValueChanged(_ => queueUpdateSearch()); + sortControl.Current.BindValueChanged(_ => queueUpdateSearch()); + sortControl.SortDirection.BindValueChanged(_ => queueUpdateSearch()); apiUser = api.LocalUser.GetBoundCopy(); apiUser.BindValueChanged(_ => queueUpdateSearch()); @@ -199,6 +200,8 @@ namespace osu.Game.Overlays.BeatmapListing performRequest(); } + private void resetSortControl() => sortControl.Reset(searchControl.Category.Value, !string.IsNullOrEmpty(searchControl.Query.Value)); + private void queueUpdateSearch(bool queryTextChanged = false) { SearchStarted?.Invoke(); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs index 454f381c60..bc1f30dcaf 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs @@ -17,18 +17,65 @@ namespace osu.Game.Overlays.BeatmapListing { public readonly Bindable SortDirection = new Bindable(Overlays.SortDirection.Descending); - public BeatmapListingSortTabControl() + private SearchCategory? lastCategory; + private bool? lastHasQuery; + + protected override void LoadComplete() { - Current.Value = SortCriteria.Ranked; + base.LoadComplete(); + Reset(SearchCategory.Leaderboard, false); + } + + public void Reset(SearchCategory category, bool hasQuery) + { + if (category != lastCategory || hasQuery != lastHasQuery) + { + TabControl.Clear(); + + TabControl.AddItem(SortCriteria.Title); + TabControl.AddItem(SortCriteria.Artist); + TabControl.AddItem(SortCriteria.Difficulty); + + if (category == SearchCategory.Any || category > SearchCategory.Loved) + TabControl.AddItem(SortCriteria.Updated); + + if (category < SearchCategory.Pending || category == SearchCategory.Mine) + TabControl.AddItem(SortCriteria.Ranked); + + TabControl.AddItem(SortCriteria.Rating); + TabControl.AddItem(SortCriteria.Plays); + TabControl.AddItem(SortCriteria.Favourites); + + if (hasQuery) + TabControl.AddItem(SortCriteria.Relevance); + + if (category == SearchCategory.Pending) + TabControl.AddItem(SortCriteria.Nominations); + } + + var nonQueryCriteria = category >= SearchCategory.Pending ? SortCriteria.Updated : SortCriteria.Ranked; + + Current.Value = hasQuery ? SortCriteria.Relevance : nonQueryCriteria; + SortDirection.Value = Overlays.SortDirection.Descending; + + // if the new criteria isn't different from the previous one, + // then re-adding tab items will not mark the current tab as selected. + // see: https://github.com/ppy/osu-framework/issues/5412 + TabControl.Current.TriggerChange(); + + lastCategory = category; + lastHasQuery = hasQuery; } protected override SortTabControl CreateControl() => new BeatmapSortTabControl { - SortDirection = { BindTarget = SortDirection } + SortDirection = { BindTarget = SortDirection }, }; private class BeatmapSortTabControl : SortTabControl { + protected override bool AddEnumEntriesAutomatically => false; + public readonly Bindable SortDirection = new Bindable(); protected override TabItem CreateTabItem(SortCriteria value) => new BeatmapSortTabItem(value) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs index 118ddfb060..09b44be6c9 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.BeatmapListing { protected override MultipleSelectionFilterTabItem CreateTabItem(ScoreRank value) => new RankItem(value); - protected override IEnumerable GetValues() => base.GetValues().Reverse(); + protected override IEnumerable GetValues() => base.GetValues().Where(r => r > ScoreRank.F).Reverse(); } private class RankItem : MultipleSelectionFilterTabItem diff --git a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs index ff6e62b274..6c010c7504 100644 --- a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs @@ -19,6 +19,9 @@ namespace osu.Game.Overlays.BeatmapListing [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] Difficulty, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingUpdated))] + Updated, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRanked))] Ranked, @@ -32,6 +35,9 @@ namespace osu.Game.Overlays.BeatmapListing Favourites, [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRelevance))] - Relevance + Relevance, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingNominations))] + Nominations, } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index cfa53009ef..2be328427b 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -104,11 +104,11 @@ namespace osu.Game.Overlays filterControl.CardSize.BindValueChanged(_ => onCardSizeChanged()); apiUser = api.LocalUser.GetBoundCopy(); - apiUser.BindValueChanged(_ => + apiUser.BindValueChanged(_ => Schedule(() => { if (api.IsLoggedIn) addContentToResultsArea(Drawable.Empty()); - }); + })); } public void ShowWithSearch(string query) @@ -176,6 +176,11 @@ namespace osu.Game.Overlays } else { + // new results may contain beatmaps from a previous page, + // this is dodgy but matches web behaviour for now. + // see: https://github.com/ppy/osu-web/issues/9270 + newCards = newCards.Except(foundContent); + panelLoadTask = LoadComponentsAsync(newCards, loaded => { lastFetchDisplayedTime = Time.Current; @@ -185,7 +190,7 @@ namespace osu.Game.Overlays } } - private BeatmapCard[] createCardsFor(IEnumerable beatmapSets) => beatmapSets.Select(set => BeatmapCard.Create(set, filterControl.CardSize.Value).With(c => + private IEnumerable createCardsFor(IEnumerable beatmapSets) => beatmapSets.Select(set => BeatmapCard.Create(set, filterControl.CardSize.Value).With(c => { c.Anchor = Anchor.TopCentre; c.Origin = Anchor.TopCentre; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index c9e97d5f2f..9e14122ae4 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; +using osu.Game.Graphics.Cursor; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; @@ -90,113 +91,118 @@ namespace osu.Game.Overlays.BeatmapSet }, }, }, - new Container + new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding + Child = new Container { - Vertical = BeatmapSetOverlay.Y_PADDING, - Left = BeatmapSetOverlay.X_PADDING, - Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, - }, - Children = new Drawable[] - { - fadeContent = new FillFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + Vertical = BeatmapSetOverlay.Y_PADDING, + Left = BeatmapSetOverlay.X_PADDING, + Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, + }, + Children = new Drawable[] + { + fadeContent = new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = Picker = new BeatmapPicker(), - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 15 }, - Children = new Drawable[] + new Container { - title = new OsuSpriteText + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = Picker = new BeatmapPicker(), + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 15 }, + Children = new Drawable[] { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.SemiBold, italics: true) - }, - externalLink = new ExternalLinkButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 5, Bottom = 4 }, // To better lineup with the font - }, - explicitContent = new ExplicitContentBeatmapBadge - { - Alpha = 0f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 10, Bottom = 4 }, - }, - spotlight = new SpotlightBeatmapBadge - { - Alpha = 0f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 10, Bottom = 4 }, + title = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 30, weight: FontWeight.SemiBold, italics: true) + }, + externalLink = new ExternalLinkButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Left = 5, Bottom = 4 }, // To better lineup with the font + }, + explicitContent = new ExplicitContentBeatmapBadge + { + Alpha = 0f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Left = 10, Bottom = 4 }, + }, + spotlight = new SpotlightBeatmapBadge + { + Alpha = 0f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Left = 10, Bottom = 4 }, + } } - } - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Bottom = 20 }, - Children = new Drawable[] + }, + new FillFlowContainer { - artist = new OsuSpriteText + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] { - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), - }, - featuredArtist = new FeaturedArtistBeatmapBadge - { - Alpha = 0f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 10 } + artist = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), + }, + featuredArtist = new FeaturedArtistBeatmapBadge + { + Alpha = 0f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Left = 10 } + } } - } - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = author = new AuthorInfo(), - }, - beatmapAvailability = new BeatmapAvailability(), - new Container - { - RelativeSizeAxes = Axes.X, - Height = buttons_height, - Margin = new MarginPadding { Top = 10 }, - Children = new Drawable[] + }, + new Container { - favouriteButton = new FavouriteButton + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = author = new AuthorInfo(), + }, + beatmapAvailability = new BeatmapAvailability(), + new Container + { + RelativeSizeAxes = Axes.X, + Height = buttons_height, + Margin = new MarginPadding { Top = 10 }, + Children = new Drawable[] { - BeatmapSet = { BindTarget = BeatmapSet } - }, - downloadButtonsContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, - Spacing = new Vector2(buttons_spacing), + favouriteButton = new FavouriteButton + { + BeatmapSet = { BindTarget = BeatmapSet } + }, + downloadButtonsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, + Spacing = new Vector2(buttons_spacing), + }, }, }, }, }, - }, - } + } + }, }, loading = new LoadingSpinner { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs index e840d0e82c..0ce55ce549 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs @@ -147,7 +147,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { // beatmapset may have changed. if (Preview != preview) + { + preview?.Dispose(); return; + } AddInternal(preview); loading = false; diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 666ceff6cb..08423f2aa7 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -70,6 +70,7 @@ namespace osu.Game.Overlays.BeatmapSet Width = metadata_width, Padding = new MarginPadding { Horizontal = 10 }, Margin = new MarginPadding { Right = BeatmapSetOverlay.RIGHT_WIDTH + spacing }, + Masking = true, Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs index c6bf94f507..317b369d8f 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -22,11 +23,14 @@ namespace osu.Game.Overlays.BeatmapSet private readonly MetadataType type; private TextFlowContainer textFlow; + private readonly Action searchAction; + private const float transition_duration = 250; - public MetadataSection(MetadataType type) + public MetadataSection(MetadataType type, Action searchAction = null) { this.type = type; + this.searchAction = searchAction; Alpha = 0; @@ -91,7 +95,12 @@ namespace osu.Game.Overlays.BeatmapSet for (int i = 0; i <= tags.Length - 1; i++) { - loaded.AddLink(tags[i], LinkAction.SearchBeatmapSet, tags[i]); + string tag = tags[i]; + + if (searchAction != null) + loaded.AddLink(tag, () => searchAction(tag)); + else + loaded.AddLink(tag, LinkAction.SearchBeatmapSet, tag); if (i != tags.Length - 1) loaded.AddText(" "); @@ -100,7 +109,11 @@ namespace osu.Game.Overlays.BeatmapSet break; case MetadataType.Source: - loaded.AddLink(text, LinkAction.SearchBeatmapSet, text); + if (searchAction != null) + loaded.AddLink(text, () => searchAction(text)); + else + loaded.AddLink(text, LinkAction.SearchBeatmapSet, text); + break; default: diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index b2e5be6601..11aefd435d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -23,7 +22,9 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Localisation; using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics.Cursor; using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring.Drawables; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -38,8 +39,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly FillFlowContainer backgroundFlow; - private Color4 highAccuracyColour; - public ScoreTable() { RelativeSizeAxes = Axes.X; @@ -57,16 +56,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - highAccuracyColour = colours.GreenLight; - } - /// /// 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; @@ -121,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)); @@ -158,27 +151,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Current = scoreManager.GetBindableTotalScoreString(score), Font = OsuFont.GetFont(size: text_size, weight: index == 0 ? FontWeight.Bold : FontWeight.Medium) }, - new OsuSpriteText + new StatisticText(score.Accuracy, 1, showTooltip: false) { Margin = new MarginPadding { Right = horizontal_inset }, Text = score.DisplayAccuracy, - Font = OsuFont.GetFont(size: text_size), - Colour = score.Accuracy == 1 ? highAccuracyColour : Color4.White }, - new UpdateableFlag(score.User.Country) + new UpdateableFlag(score.User.CountryCode) { Size = new Vector2(19, 14), - ShowPlaceholderOnNull = false, + ShowPlaceholderOnUnknown = false, }, username, - new OsuSpriteText - { - Text = score.MaxCombo.ToLocalisableString(@"0\x"), - Font = OsuFont.GetFont(size: text_size), #pragma warning disable 618 - Colour = score.MaxCombo == score.BeatmapInfo.MaxCombo ? highAccuracyColour : Color4.White + new StatisticText(score.MaxCombo, score.BeatmapInfo.MaxCombo, @"0\x"), #pragma warning restore 618 - } }; var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result); @@ -188,23 +174,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (!availableStatistics.TryGetValue(result.result, out var stat)) stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName); - content.Add(new OsuSpriteText - { - Text = stat.MaxCount == null ? stat.Count.ToLocalisableString(@"N0") : (LocalisableString)$"{stat.Count}/{stat.MaxCount}", - Font = OsuFont.GetFont(size: text_size), - Colour = stat.Count == 0 ? Color4.Gray : Color4.White - }); + content.Add(new StatisticText(stat.Count, stat.MaxCount, @"N0") { Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); } if (showPerformancePoints) { - Debug.Assert(score.PP != null); - - content.Add(new OsuSpriteText - { - Text = score.PP.ToLocalisableString(@"N0"), - Font = OsuFont.GetFont(size: text_size) - }); + if (score.PP != null) + content.Add(new StatisticText(score.PP, format: @"N0")); + else + content.Add(new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(text_size) }); } content.Add(new ScoreboardTime(score.Date, text_size) @@ -243,5 +221,31 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Colour = colourProvider.Foreground1; } } + + private class StatisticText : OsuSpriteText, IHasTooltip + { + private readonly double? count; + private readonly double? maxCount; + private readonly bool showTooltip; + + public LocalisableString TooltipText => maxCount == null || !showTooltip ? string.Empty : $"{count}/{maxCount}"; + + public StatisticText(double? count, double? maxCount = null, string format = null, bool showTooltip = true) + { + this.count = count; + this.maxCount = maxCount; + this.showTooltip = showTooltip; + + Text = count?.ToLocalisableString(format) ?? default; + Font = OsuFont.GetFont(size: text_size); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + if (count == maxCount) + Colour = colours.GreenLight; + } + } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index c2e54d0d7b..53818bbee3 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -6,10 +6,8 @@ using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -87,27 +85,19 @@ namespace osu.Game.Overlays.BeatmapSet.Scores MD5Hash = apiBeatmap.MD5Hash }; - scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token) - .ContinueWith(task => Schedule(() => - { - if (loadCancellationSource.IsCancellationRequested) - return; + var scores = scoreManager.OrderByTotalScore(value.Scores.Select(s => s.ToScoreInfo(rulesets, beatmapInfo))).ToArray(); + var topScore = scores.First(); - var scores = task.GetResultSafely(); + scoreTable.DisplayScores(scores, apiBeatmap.Status.GrantsPerformancePoints()); + scoreTable.Show(); - var topScore = scores.First(); + var userScore = value.UserScore; + var userScoreInfo = userScore?.Score.ToScoreInfo(rulesets, beatmapInfo); - scoreTable.DisplayScores(scores, apiBeatmap.Status.GrantsPerformancePoints()); - scoreTable.Show(); + topScoresContainer.Add(new DrawableTopScore(topScore)); - var userScore = value.UserScore; - var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets, beatmapInfo); - - topScoresContainer.Add(new DrawableTopScore(topScore)); - - if (userScoreInfo != null && userScoreInfo.OnlineID != topScore.OnlineID) - topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); - }), TaskContinuationOptions.OnlyOnRanToCompletion); + if (userScoreInfo != null && userScoreInfo.OnlineID != topScore.OnlineID) + topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); }); } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 3b5ab811ae..653bfd6d2c 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -12,14 +12,17 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Scoring.Drawables; using osuTK; namespace osu.Game.Overlays.BeatmapSet.Scores @@ -121,7 +124,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores maxComboColumn.Text = value.MaxCombo.ToLocalisableString(@"0\x"); ppColumn.Alpha = value.BeatmapInfo.Status.GrantsPerformancePoints() ? 1 : 0; - ppColumn.Text = value.PP?.ToLocalisableString(@"N0") ?? default; + + if (value.PP is double pp) + ppColumn.Text = pp.ToLocalisableString(@"N0"); + else + ppColumn.Drawable = new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(smallFont.Size) }; statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); modsColumn.Mods = value.Mods; @@ -197,30 +204,48 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private class TextColumn : InfoColumn + private class TextColumn : InfoColumn, IHasCurrentValue { - private readonly SpriteText text; - - public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) - : this(title, new OsuSpriteText { Font = font }, minWidth) - { - } - - private TextColumn(LocalisableString title, SpriteText text, float? minWidth = null) - : base(title, text, minWidth) - { - this.text = text; - } + private readonly OsuTextFlowContainer text; public LocalisableString Text { set => text.Text = value; } + public Drawable Drawable + { + set + { + text.Clear(); + text.AddArbitraryDrawable(value); + } + } + + private Bindable current; + public Bindable Current { - get => text.Current; - set => text.Current = value; + get => current; + set + { + text.Clear(); + text.AddText(value.Value, t => t.Current = current = value); + } + } + + public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) + : this(title, new OsuTextFlowContainer(t => t.Font = font) + { + AutoSizeAxes = Axes.Both + }, minWidth) + { + } + + private TextColumn(LocalisableString title, OsuTextFlowContainer text, float? minWidth = null) + : base(title, text, minWidth) + { + this.text = text; } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index f093c6b53f..2eaa03a05d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -120,7 +120,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Origin = Anchor.CentreLeft, Size = new Vector2(19, 14), Margin = new MarginPadding { Top = 3 }, // makes spacing look more even - ShowPlaceholderOnNull = false, + ShowPlaceholderOnUnknown = false, }, } } @@ -141,7 +141,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set { avatar.User = value.User; - flag.Country = value.User.Country; + flag.CountryCode = value.User.CountryCode; achievedOn.Date = value.Date; usernameText.Clear(); diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 823d0023cf..207dc91ca5 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -3,7 +3,10 @@ #nullable disable +using System; +using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,6 +15,8 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.Comments; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; @@ -25,6 +30,14 @@ namespace osu.Game.Overlays private readonly Bindable beatmapSet = new Bindable(); + /// + /// Isolates the beatmap set overlay from the game-wide selected mods bindable + /// to avoid affecting the beatmap details section (i.e. ). + /// + [Cached] + [Cached(typeof(IBindable>))] + protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); + public BeatmapSetOverlay() : base(OverlayColourScheme.Blue) { diff --git a/osu.Game/Overlays/Changelog/ChangelogEntry.cs b/osu.Game/Overlays/Changelog/ChangelogEntry.cs index fdeba3f304..4d034007b1 100644 --- a/osu.Game/Overlays/Changelog/ChangelogEntry.cs +++ b/osu.Game/Overlays/Changelog/ChangelogEntry.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Diagnostics; using System.Net; using System.Text.RegularExpressions; using osu.Framework.Allocation; @@ -93,7 +94,7 @@ namespace osu.Game.Overlays.Changelog t.Colour = entryColour; }); - if (!string.IsNullOrEmpty(entry.Repository)) + if (!string.IsNullOrEmpty(entry.Repository) && !string.IsNullOrEmpty(entry.GithubUrl)) addRepositoryReference(title, entryColour); if (entry.GithubUser != null) @@ -104,17 +105,22 @@ namespace osu.Game.Overlays.Changelog private void addRepositoryReference(LinkFlowContainer title, Color4 entryColour) { + Debug.Assert(!string.IsNullOrEmpty(entry.Repository)); + Debug.Assert(!string.IsNullOrEmpty(entry.GithubUrl)); + title.AddText(" (", t => { t.Font = fontLarge; t.Colour = entryColour; }); + title.AddLink($"{entry.Repository.Replace("ppy/", "")}#{entry.GithubPullRequestId}", entry.GithubUrl, t => { t.Font = fontLarge; t.Colour = entryColour; }); + title.AddText(")", t => { t.Font = fontLarge; diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs index b3814ca90c..d9f962ca97 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs @@ -99,7 +99,7 @@ namespace osu.Game.Overlays.Chat.ChannelList FillFlowContainer flow = getFlowForChannel(channel); channelMap.Remove(channel); - flow.Remove(item); + flow.Remove(item, true); updateVisibility(); } diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 5961298485..4c425d3d4c 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -257,10 +257,14 @@ namespace osu.Game.Overlays.Chat } [BackgroundDependencyLoader] - private void load(UserProfileOverlay? profile, ChannelManager? chatManager) + private void load(UserProfileOverlay? profile, ChannelManager? chatManager, ChatOverlay? chatOverlay) { Action = () => profile?.ShowUser(sender); - startChatAction = () => chatManager?.OpenPrivateChannel(sender); + startChatAction = () => + { + chatManager?.OpenPrivateChannel(sender); + chatOverlay?.Show(); + }; } public MenuItem[] ContextMenuItems diff --git a/osu.Game/Overlays/Chat/ChatTextBox.cs b/osu.Game/Overlays/Chat/ChatTextBox.cs index ae8816b009..887eb96c15 100644 --- a/osu.Game/Overlays/Chat/ChatTextBox.cs +++ b/osu.Game/Overlays/Chat/ChatTextBox.cs @@ -13,6 +13,8 @@ namespace osu.Game.Overlays.Chat public override bool HandleLeftRightArrows => !ShowSearch.Value; + protected override bool ClearTextOnBackKey => false; + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index c05f456a96..544daf7d2c 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -181,7 +181,7 @@ namespace osu.Game.Overlays.Chat { Trace.Assert(updated.Id.HasValue, "An updated message was returned with no ID."); - ChatLineFlow.Remove(found); + ChatLineFlow.Remove(found, false); found.Message = updated; ChatLineFlow.Add(found); } 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/Comments/CommentMarkdownContainer.cs b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs index e94a1b0147..b32b1c74c4 100644 --- a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs +++ b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs @@ -4,7 +4,6 @@ #nullable disable using Markdig.Syntax; -using Markdig.Syntax.Inlines; using osu.Framework.Graphics.Containers.Markdown; using osu.Game.Graphics.Containers.Markdown; @@ -12,16 +11,10 @@ namespace osu.Game.Overlays.Comments { public class CommentMarkdownContainer : OsuMarkdownContainer { - public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer(); + protected override bool Autolinks => true; protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock); - private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer - { - // Don't render image in comment for now - protected override void AddImage(LinkInline linkInline) { } - } - private class CommentMarkdownHeading : OsuMarkdownHeading { public CommentMarkdownHeading(HeadingBlock headingBlock) diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 296320ec1b..0675d99a25 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.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.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -22,7 +20,13 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Overlays.Comments.Buttons; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.OSD; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments @@ -31,7 +35,7 @@ namespace osu.Game.Overlays.Comments { private const int avatar_size = 40; - public Action RepliesRequested; + public Action RepliesRequested = null!; public readonly Comment Comment; @@ -45,13 +49,35 @@ namespace osu.Game.Overlays.Comments private int currentPage; - private FillFlowContainer childCommentsVisibilityContainer; - private FillFlowContainer childCommentsContainer; - private LoadRepliesButton loadRepliesButton; - private ShowMoreRepliesButton showMoreButton; - private ShowRepliesButton showRepliesButton; - private ChevronButton chevronButton; - private DeletedCommentsCounter deletedCommentsCounter; + /// + /// Local field for tracking comment state. Initialized from Comment.IsDeleted, may change when deleting was requested by user. + /// + public bool WasDeleted { get; protected set; } + + private FillFlowContainer childCommentsVisibilityContainer = null!; + private FillFlowContainer childCommentsContainer = null!; + private LoadRepliesButton loadRepliesButton = null!; + private ShowMoreRepliesButton showMoreButton = null!; + private ShowRepliesButton showRepliesButton = null!; + private ChevronButton chevronButton = null!; + private LinkFlowContainer actionsContainer = null!; + private LoadingSpinner actionsLoading = null!; + private DeletedCommentsCounter deletedCommentsCounter = null!; + private OsuSpriteText deletedLabel = null!; + private GridContainer content = null!; + private VotePill votePill = null!; + + [Resolved(canBeNull: true)] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved(canBeNull: true)] + private OnScreenDisplay? onScreenDisplay { get; set; } public DrawableComment(Comment comment) { @@ -64,8 +90,6 @@ namespace osu.Game.Overlays.Comments LinkFlowContainer username; FillFlowContainer info; CommentMarkdownContainer message; - GridContainer content; - VotePill votePill; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -148,9 +172,9 @@ namespace osu.Game.Overlays.Comments }, Comment.Pinned ? new PinnedCommentNotice() : Empty(), new ParentUsername(Comment), - new OsuSpriteText + deletedLabel = new OsuSpriteText { - Alpha = Comment.IsDeleted ? 1 : 0, + Alpha = 0f, Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), Text = CommentsStrings.Deleted } @@ -163,16 +187,36 @@ namespace osu.Game.Overlays.Comments DocumentMargin = new MarginPadding(0), DocumentPadding = new MarginPadding(0), }, - info = new FillFlowContainer + new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(10, 0), Children = new Drawable[] { - new DrawableDate(Comment.CreatedAt, 12, false) + info = new FillFlowContainer { - Colour = colourProvider.Foreground1 + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new DrawableDate(Comment.CreatedAt, 12, false) + { + Colour = colourProvider.Foreground1 + } + } + }, + actionsContainer = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold)) + { + Name = @"Actions buttons", + AutoSizeAxes = Axes.Both, + }, + actionsLoading = new LoadingSpinner + { + Size = new Vector2(12f), + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft } } }, @@ -246,9 +290,9 @@ namespace osu.Game.Overlays.Comments if (Comment.UserId.HasValue) username.AddUserLink(Comment.User); else - username.AddText(Comment.LegacyName); + username.AddText(Comment.LegacyName!); - if (Comment.EditedAt.HasValue) + if (Comment.EditedAt.HasValue && Comment.EditedUser != null) { var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); var colour = colourProvider.Foreground1; @@ -282,10 +326,16 @@ namespace osu.Game.Overlays.Comments if (Comment.HasMessage) message.Text = Comment.Message; - if (Comment.IsDeleted) + WasDeleted = Comment.IsDeleted; + if (WasDeleted) + makeDeleted(); + + actionsContainer.AddLink("Copy link", copyUrl); + actionsContainer.AddArbitraryDrawable(new Container { Width = 10 }); + + if (Comment.UserId.HasValue && Comment.UserId.Value == api.LocalUser.Value.Id) { - content.FadeColour(OsuColour.Gray(0.5f)); - votePill.Hide(); + actionsContainer.AddLink("Delete", deleteComment); } if (Comment.IsTopLevel) @@ -317,11 +367,63 @@ namespace osu.Game.Overlays.Comments }; } + /// + /// Transforms some comment's components to show it as deleted. Invoked both from loading and deleting. + /// + private void makeDeleted() + { + deletedLabel.Show(); + content.FadeColour(OsuColour.Gray(0.5f)); + votePill.Hide(); + actionsContainer.Expire(); + } + + /// + /// Invokes comment deletion with confirmation. + /// + private void deleteComment() + { + if (dialogOverlay == null) + deleteCommentRequest(); + else + dialogOverlay.Push(new ConfirmDialog("Do you really want to delete your comment?", deleteCommentRequest)); + } + + /// + /// Invokes comment deletion directly. + /// + private void deleteCommentRequest() + { + actionsContainer.Hide(); + actionsLoading.Show(); + var request = new CommentDeleteRequest(Comment.Id); + request.Success += _ => Schedule(() => + { + actionsLoading.Hide(); + makeDeleted(); + WasDeleted = true; + if (!ShowDeleted.Value) + Hide(); + }); + request.Failure += _ => Schedule(() => + { + actionsLoading.Hide(); + actionsContainer.Show(); + }); + api.Queue(request); + } + + private void copyUrl() + { + host.GetClipboard()?.SetText($@"{api.APIEndpointUrl}/comments/{Comment.Id}"); + onScreenDisplay?.Display(new CopyUrlToast()); + } + protected override void LoadComplete() { ShowDeleted.BindValueChanged(show => { - if (Comment.IsDeleted) + if (WasDeleted) this.FadeTo(show.NewValue ? 1 : 0); }, true); childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); @@ -425,7 +527,7 @@ namespace osu.Game.Overlays.Comments { public LocalisableString TooltipText => getParentMessage(); - private readonly Comment parentComment; + private readonly Comment? parentComment; public ParentUsername(Comment comment) { @@ -445,7 +547,7 @@ namespace osu.Game.Overlays.Comments new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = parentComment?.User?.Username ?? parentComment?.LegacyName + Text = parentComment?.User?.Username ?? parentComment?.LegacyName! } }; } diff --git a/osu.Game/Overlays/Dialog/ConfirmDialog.cs b/osu.Game/Overlays/Dialog/ConfirmDialog.cs index 8be865ee16..c17080f602 100644 --- a/osu.Game/Overlays/Dialog/ConfirmDialog.cs +++ b/osu.Game/Overlays/Dialog/ConfirmDialog.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dialog @@ -20,7 +21,7 @@ namespace osu.Game.Overlays.Dialog /// The description of the action to be displayed to the user. /// An action to perform on confirmation. /// An optional action to perform on cancel. - public ConfirmDialog(string message, Action onConfirm, Action onCancel = null) + public ConfirmDialog(LocalisableString message, Action onConfirm, Action onCancel = null) { HeaderText = message; BodyText = "Last chance to turn back"; diff --git a/osu.Game/Overlays/Dialog/DeleteConfirmationDialog.cs b/osu.Game/Overlays/Dialog/DeleteConfirmationDialog.cs new file mode 100644 index 0000000000..fd26dd7e8e --- /dev/null +++ b/osu.Game/Overlays/Dialog/DeleteConfirmationDialog.cs @@ -0,0 +1,42 @@ +// 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.Graphics.Sprites; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// Base class for various confirmation dialogs that concern deletion actions. + /// Differs from in that the confirmation button is a "dangerous" one + /// (requires the confirm button to be held). + /// + public abstract class DeleteConfirmationDialog : PopupDialog + { + /// + /// The action which performs the deletion. + /// + protected Action? DeleteAction { get; set; } + + protected DeleteConfirmationDialog() + { + HeaderText = DeleteConfirmationDialogStrings.HeaderText; + + Icon = FontAwesome.Regular.TrashAlt; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = DeleteConfirmationDialogStrings.Confirm, + Action = () => DeleteAction?.Invoke() + }, + new PopupDialogCancelButton + { + Text = DeleteConfirmationDialogStrings.Cancel + } + }; + } + } +} diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index a07cf1608d..ba8083e535 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -49,43 +49,54 @@ namespace osu.Game.Overlays public void Push(PopupDialog dialog) { - if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return; - - var lastDialog = CurrentDialog; + if (dialog == CurrentDialog || dialog.State.Value == Visibility.Hidden) return; // Immediately update the externally accessible property as this may be used for checks even before // a DialogOverlay instance has finished loading. + var lastDialog = CurrentDialog; CurrentDialog = dialog; - Scheduler.Add(() => + Schedule(() => { // if any existing dialog is being displayed, dismiss it before showing a new one. lastDialog?.Hide(); - dialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue); - dialogContainer.Add(dialog); + // if the new dialog is hidden before added to the dialogContainer, bypass any further operations. + if (dialog.State.Value == Visibility.Hidden) + { + dismiss(); + return; + } + + dialogContainer.Add(dialog); Show(); - }, false); + + dialog.State.BindValueChanged(state => + { + if (state.NewValue != Visibility.Hidden) return; + + // Trigger the demise of the dialog as soon as it hides. + dialog.Delay(PopupDialog.EXIT_DURATION).Expire(); + + dismiss(); + }); + }); + + void dismiss() + { + if (dialog != CurrentDialog) return; + + // Handle the case where the dialog is the currently displayed dialog. + // In this scenario, the overlay itself should also be hidden. + Hide(); + CurrentDialog = null; + } } public override bool IsPresent => Scheduler.HasPendingTasks || dialogContainer.Children.Count > 0; protected override bool BlockNonPositionalInput => true; - private void onDialogOnStateChanged(VisibilityContainer dialog, Visibility v) - { - if (v != Visibility.Hidden) return; - - // handle the dialog being dismissed. - dialog.Delay(PopupDialog.EXIT_DURATION).Expire(); - - if (dialog == CurrentDialog) - { - Hide(); - CurrentDialog = null; - } - } - protected override void PopIn() { base.PopIn(); @@ -97,7 +108,8 @@ namespace osu.Game.Overlays base.PopOut(); lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); - if (CurrentDialog?.State.Value == Visibility.Visible) + // PopOut gets called initially, but we only want to hide dialog when we have been loaded and are present. + if (IsLoaded && CurrentDialog?.State.Value == Visibility.Visible) CurrentDialog.Hide(); } @@ -109,7 +121,11 @@ namespace osu.Game.Overlays switch (e.Action) { case GlobalAction.Select: - CurrentDialog?.Buttons.OfType().FirstOrDefault()?.TriggerClick(); + var clickableButton = + CurrentDialog?.Buttons.OfType().FirstOrDefault() ?? + CurrentDialog?.Buttons.First(); + + clickableButton?.TriggerClick(); return true; } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index 82f49e0aef..4963de7251 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -123,7 +123,7 @@ namespace osu.Game.Overlays.FirstRunSetup beatmapSubscription?.Dispose(); } - private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception error) + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception error) => Schedule(() => { currentlyLoadedBeatmaps.Text = FirstRunSetupBeatmapScreenStrings.CurrentlyLoadedBeatmaps(sender.Count); @@ -139,7 +139,7 @@ namespace osu.Game.Overlays.FirstRunSetup currentlyLoadedBeatmaps.ScaleTo(1.1f) .ScaleTo(1, 1500, Easing.OutQuint); } - } + }); private void downloadTutorial() { diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index 58bfced3ed..0d4496a6a3 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -161,7 +161,6 @@ namespace osu.Game.Overlays.FirstRunSetup private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { Beatmap.Value = new DummyWorkingBeatmap(audio, textures); - Beatmap.Value.LoadTrack(); Ruleset.Value = rulesets.AvailableRulesets.First(); diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs index 20ff8f21c8..cb1e96d2f2 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -1,14 +1,24 @@ // 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Localisation; +using osuTK; namespace osu.Game.Overlays.FirstRunSetup { @@ -20,13 +30,175 @@ namespace osu.Game.Overlays.FirstRunSetup { Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + // Avoid height changes when changing language. + new Dimension(GridSizeMode.AutoSize, minSize: 100), + }, + Content = new[] + { + new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Text = FirstRunSetupOverlayStrings.WelcomeDescription, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + }, + } + }, + new LanguageSelectionFlow { - Text = FirstRunSetupOverlayStrings.WelcomeDescription, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y - }, + } }; } + + private class LanguageSelectionFlow : FillFlowContainer + { + private Bindable frameworkLocale = null!; + + private ScheduledDelegate? updateSelectedDelegate; + + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager frameworkConfig) + { + Direction = FillDirection.Full; + Spacing = new Vector2(5); + + ChildrenEnumerable = Enum.GetValues(typeof(Language)) + .Cast() + .Select(l => new LanguageButton(l) + { + Action = () => frameworkLocale.Value = l.ToCultureCode() + }); + + frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); + frameworkLocale.BindValueChanged(locale => + { + if (!LanguageExtensions.TryParseCultureCode(locale.NewValue, out var language)) + language = Language.en; + + // Changing language may cause a short period of blocking the UI thread while the new glyphs are loaded. + // Scheduling ensures the button animation plays smoothly after any blocking operation completes. + // Note that a delay is required (the alternative would be a double-schedule; delay feels better). + updateSelectedDelegate?.Cancel(); + updateSelectedDelegate = Scheduler.AddDelayed(() => updateSelectedStates(language), 50); + }, true); + } + + private void updateSelectedStates(Language language) + { + foreach (var c in Children.OfType()) + c.Selected = c.Language == language; + } + + private class LanguageButton : OsuClickableContainer + { + public readonly Language Language; + + private Box backgroundBox = null!; + + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private bool selected; + + public bool Selected + { + get => selected; + set + { + if (selected == value) + return; + + selected = value; + + if (IsLoaded) + updateState(); + } + } + + public LanguageButton(Language language) + { + Language = language; + + Size = new Vector2(160, 50); + Masking = true; + CornerRadius = 10; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + backgroundBox = new Box + { + Alpha = 0, + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colourProvider.Light1, + Text = Language.GetDescription(), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + if (!selected) + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + if (!selected) + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + if (selected) + { + const double selected_duration = 1000; + + backgroundBox.FadeTo(1, selected_duration, Easing.OutQuint); + backgroundBox.FadeColour(colourProvider.Background2, selected_duration, Easing.OutQuint); + text.FadeColour(colourProvider.Content1, selected_duration, Easing.OutQuint); + text.ScaleTo(1.2f, selected_duration, Easing.OutQuint); + } + else + { + const double duration = 500; + + backgroundBox.FadeTo(IsHovered ? 1 : 0, duration, Easing.OutQuint); + backgroundBox.FadeColour(colourProvider.Background5, duration, Easing.OutQuint); + text.FadeColour(colourProvider.Light1, duration, Easing.OutQuint); + text.ScaleTo(1, duration, Easing.OutQuint); + } + } + } + } } } diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 1d8926b991..32a7fca1a6 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Users; using osuTK; @@ -109,7 +110,7 @@ namespace osu.Game.Overlays.Login Origin = Anchor.TopCentre, TextAnchor = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Text = state.NewValue == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ", + Text = state.NewValue == APIState.Failing ? ToolbarStrings.AttemptingToReconnect : ToolbarStrings.Connecting, Margin = new MarginPadding { Top = 10, Bottom = 10 }, }, }; diff --git a/osu.Game/Overlays/Mods/AddPresetButton.cs b/osu.Game/Overlays/Mods/AddPresetButton.cs new file mode 100644 index 0000000000..1242088cf5 --- /dev/null +++ b/osu.Game/Overlays/Mods/AddPresetButton.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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public class AddPresetButton : ShearedToggleButton, IHasPopover + { + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + public AddPresetButton() + : base(1) + { + RelativeSizeAxes = Axes.X; + Height = ModSelectPanel.HEIGHT; + + // shear will be applied at a higher level in `ModPresetColumn`. + Content.Shear = Vector2.Zero; + Padding = new MarginPadding(); + + Text = "+"; + TextSize = 30; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedMods.BindValueChanged(mods => Enabled.Value = mods.NewValue.Any(), true); + Enabled.BindValueChanged(enabled => + { + if (!enabled.NewValue) + Active.Value = false; + }); + } + + protected override void UpdateActiveState() + { + DarkerColour = Active.Value ? colours.Orange1 : ColourProvider.Background3; + LighterColour = Active.Value ? colours.Orange0 : ColourProvider.Background1; + TextColour = Active.Value ? ColourProvider.Background6 : ColourProvider.Content1; + + if (Active.Value) + this.ShowPopover(); + else + this.HidePopover(); + } + + public Popover GetPopover() => new AddPresetPopover(this); + } +} diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs new file mode 100644 index 0000000000..8188c98e46 --- /dev/null +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -0,0 +1,120 @@ +// 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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + internal class AddPresetPopover : OsuPopover + { + private readonly AddPresetButton button; + + private readonly LabelledTextBox nameTextBox; + private readonly LabelledTextBox descriptionTextBox; + private readonly ShearedButton createButton; + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public AddPresetPopover(AddPresetButton addPresetButton) + { + button = addPresetButton; + + Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Children = new Drawable[] + { + nameTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Name, + TabbableContentContainer = this + }, + descriptionTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Description, + TabbableContentContainer = this + }, + createButton = new ShearedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = ModSelectOverlayStrings.AddPreset, + Action = tryCreatePreset + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + Body.BorderThickness = 3; + Body.BorderColour = colours.Orange1; + + createButton.DarkerColour = colours.Orange1; + createButton.LighterColour = colours.Orange0; + createButton.TextColour = colourProvider.Background6; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + } + + private void tryCreatePreset() + { + if (string.IsNullOrWhiteSpace(nameTextBox.Current.Value)) + { + Body.Shake(); + return; + } + + realm.Write(r => r.Add(new ModPreset + { + Name = nameTextBox.Current.Value, + Description = descriptionTextBox.Current.Value, + Mods = selectedMods.Value.ToArray(), + Ruleset = r.Find(ruleset.Value.ShortName) + })); + + this.HidePopover(); + } + + protected override void UpdateState(ValueChangedEvent state) + { + base.UpdateState(state); + if (state.NewValue == Visibility.Hidden) + button.Active.Value = false; + } + } +} diff --git a/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs new file mode 100644 index 0000000000..b3a19e35ce --- /dev/null +++ b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs @@ -0,0 +1,18 @@ +// 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.Database; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + public class DeleteModPresetDialog : DeleteConfirmationDialog + { + public DeleteModPresetDialog(Live modPreset) + { + BodyText = modPreset.PerformRead(preset => preset.Name); + DeleteAction = () => modPreset.PerformWrite(preset => preset.DeletePending = true); + } + } +} diff --git a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs index c8d55c213a..f582a010c6 100644 --- a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs +++ b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs @@ -3,188 +3,41 @@ #nullable disable -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets.Mods; using osuTK; using osu.Game.Localisation; namespace osu.Game.Overlays.Mods { - public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue + public sealed class DifficultyMultiplierDisplay : ModsEffectDisplay { - public const float HEIGHT = 42; + protected override LocalisableString Label => DifficultyMultiplierDisplayStrings.DifficultyMultiplier; - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } - - private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(1) - { - Precision = 0.01 - }; - - private readonly Box underlayBackground; - private readonly Box contentBackground; - private readonly FillFlowContainer multiplierFlow; - private readonly MultiplierCounter multiplierCounter; - - [Resolved] - private OsuColour colours { get; set; } - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } - - private const float multiplier_value_area_width = 56; - private const float transition_duration = 200; + protected override string CounterFormat => @"N2"; public DifficultyMultiplierDisplay() { - Height = HEIGHT; - AutoSizeAxes = Axes.X; - - InternalChild = new InputBlockingContainer + Current.Default = 1d; + Current.Value = 1d; + Add(new SpriteIcon { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0), - Children = new Drawable[] - { - underlayBackground = new Box - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Y, - Width = multiplier_value_area_width + ModPanel.CORNER_RADIUS - }, - new GridContainer - { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, multiplier_value_area_width) - }, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - Children = new Drawable[] - { - contentBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Margin = new MarginPadding { Horizontal = 18 }, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Text = DifficultyMultiplierDisplayStrings.DifficultyMultiplier, - Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) - } - } - }, - multiplierFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2, 0), - Children = new Drawable[] - { - multiplierCounter = new MultiplierCounter - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = { BindTarget = Current } - }, - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.Times, - Size = new Vector2(7), - Margin = new MarginPadding { Top = 1 } - } - } - } - } - } - } - } - }; - } - - [BackgroundDependencyLoader] - private void load() - { - contentBackground.Colour = colourProvider.Background4; + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Times, + Size = new Vector2(7), + Margin = new MarginPadding { Top = 1 } + }); } protected override void LoadComplete() { base.LoadComplete(); - current.BindValueChanged(_ => updateState(), true); - // required to prevent the counter initially rolling up from 0 to 1 // due to `Current.Value` having a nonstandard default value of 1. - multiplierCounter.SetCountWithoutRolling(Current.Value); - } - - private void updateState() - { - if (Current.IsDefault) - { - underlayBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint); - multiplierFlow.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); - } - else - { - var backgroundColour = Current.Value < 1 - ? colours.ForModType(ModType.DifficultyReduction) - : colours.ForModType(ModType.DifficultyIncrease); - - underlayBackground.FadeColour(backgroundColour, transition_duration, Easing.OutQuint); - multiplierFlow.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); - } - } - - private class MultiplierCounter : RollingCounter - { - protected override double RollingDuration => 500; - - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N2"); - - protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText - { - Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) - }; + Counter.SetCountWithoutRolling(Current.Value); } } } diff --git a/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs b/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs index 6375b37f8b..abb7a804da 100644 --- a/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs +++ b/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs @@ -1,7 +1,9 @@ // 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; +using osu.Game.Localisation; namespace osu.Game.Overlays.Mods.Input { @@ -15,6 +17,7 @@ namespace osu.Game.Overlays.Mods.Input /// Individual letters in a row trigger the mods in a sequential fashion. /// Uses . /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.SequentialHotkeyStyle))] Sequential, /// @@ -22,6 +25,7 @@ namespace osu.Game.Overlays.Mods.Input /// One keybinding can toggle between what used to be s on stable, /// and some mods in a column may not have any hotkeys at all. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.ClassicHotkeyStyle))] Classic } } diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 3f788d10e3..b9f7114f74 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -12,14 +12,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays.Mods.Input; @@ -29,10 +25,8 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Mods { - public class ModColumn : CompositeDrawable + public class ModColumn : ModSelectColumn { - public readonly Container TopLevelContent; - public readonly ModType ModType; private IReadOnlyList availableMods = Array.Empty(); @@ -62,149 +56,32 @@ namespace osu.Game.Overlays.Mods } } - /// - /// Determines whether this column should accept user input. - /// - public Bindable Active = new BindableBool(true); - - protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; - protected virtual ModPanel CreateModPanel(ModState mod) => new ModPanel(mod); private readonly bool allowIncompatibleSelection; - private readonly TextFlowContainer headerText; - private readonly Box headerBackground; - private readonly Container contentContainer; - private readonly Box contentBackground; - private readonly FillFlowContainer panelFlow; private readonly ToggleAllCheckbox? toggleAllCheckbox; - private Colour4 accentColour; - private Bindable hotkeyStyle = null!; private IModHotkeyHandler hotkeyHandler = null!; private Task? latestLoadTask; - internal bool ItemsLoaded => latestLoadTask == null; + private ICollection? latestLoadedPanels; + internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true && latestLoadedPanels?.All(panel => panel.Parent != null) == true; - private const float header_height = 42; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public ModColumn(ModType modType, bool allowIncompatibleSelection) { ModType = modType; this.allowIncompatibleSelection = allowIncompatibleSelection; - Width = 320; - RelativeSizeAxes = Axes.Y; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); - - Container controlContainer; - InternalChildren = new Drawable[] - { - TopLevelContent = new Container - { - RelativeSizeAxes = Axes.Both, - CornerRadius = ModPanel.CORNER_RADIUS, - Masking = true, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - Height = header_height + ModPanel.CORNER_RADIUS, - Children = new Drawable[] - { - headerBackground = new Box - { - RelativeSizeAxes = Axes.X, - Height = header_height + ModPanel.CORNER_RADIUS - }, - headerText = new OsuTextFlowContainer(t => - { - t.Font = OsuFont.TorusAlternate.With(size: 17); - t.Shadow = false; - t.Colour = Colour4.Black; - }) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Padding = new MarginPadding - { - Horizontal = 17, - Bottom = ModPanel.CORNER_RADIUS - } - } - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = header_height }, - Child = contentContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - BorderThickness = 3, - Children = new Drawable[] - { - contentBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension() - }, - Content = new[] - { - new Drawable[] - { - controlContainer = new Container - { - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 14 } - } - }, - new Drawable[] - { - new OsuScrollContainer(Direction.Vertical) - { - RelativeSizeAxes = Axes.Both, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = panelFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 7), - Padding = new MarginPadding(7) - } - } - } - } - } - } - } - } - } - } - }; - - createHeaderText(); + HeaderText = ModType.Humanize(LetterCasing.Title); if (allowIncompatibleSelection) { - controlContainer.Height = 35; - controlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this) + ControlContainer.Height = 35; + ControlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -212,7 +89,7 @@ namespace osu.Game.Overlays.Mods RelativeSizeAxes = Axes.X, Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) }); - panelFlow.Padding = new MarginPadding + ItemsFlow.Padding = new MarginPadding { Top = 0, Bottom = 7, @@ -221,33 +98,17 @@ namespace osu.Game.Overlays.Mods } } - private void createHeaderText() - { - IEnumerable headerTextWords = ModType.Humanize(LetterCasing.Title).Split(' '); - - if (headerTextWords.Count() > 1) - { - headerText.AddText($"{headerTextWords.First()} ", t => t.Font = t.Font.With(weight: FontWeight.SemiBold)); - headerTextWords = headerTextWords.Skip(1); - } - - headerText.AddText(string.Join(' ', headerTextWords)); - } - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuColour colours, OsuConfigManager configManager) + private void load(OsuColour colours, OsuConfigManager configManager) { - headerBackground.Colour = accentColour = colours.ForModType(ModType); + AccentColour = colours.ForModType(ModType); if (toggleAllCheckbox != null) { - toggleAllCheckbox.AccentColour = accentColour; - toggleAllCheckbox.AccentHoverColour = accentColour.Lighten(0.3f); + toggleAllCheckbox.AccentColour = AccentColour; + toggleAllCheckbox.AccentHoverColour = AccentColour.Lighten(0.3f); } - contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3); - contentBackground.Colour = colourProvider.Background4; - hotkeyStyle = configManager.GetBindable(OsuSetting.ModSelectHotkeyStyle); } @@ -272,20 +133,14 @@ 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; - Task? loadTask; - - latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded => + latestLoadTask = LoadComponentsAsync(panels, loaded => { - panelFlow.ChildrenEnumerable = loaded; + ItemsFlow.ChildrenEnumerable = loaded; updateState(); }, (cancellationTokenSource = new CancellationTokenSource()).Token); - loadTask.ContinueWith(_ => - { - if (loadTask == latestLoadTask) - latestLoadTask = null; - }); } private void updateState() diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index 02eb395bd9..7bdb9511ac 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -1,144 +1,42 @@ // 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.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Framework.Utils; -using osu.Game.Audio; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osuTK; -using osuTK.Input; namespace osu.Game.Overlays.Mods { - public class ModPanel : OsuClickableContainer + public class ModPanel : ModSelectPanel { public Mod Mod => modState.Mod; - public BindableBool Active => modState.Active; + public override BindableBool Active => modState.Active; public BindableBool Filtered => modState.Filtered; + protected override float IdleSwitchWidth => 54; + protected override float ExpandedSwitchWidth => 70; + private readonly ModState modState; - protected readonly Box Background; - protected readonly Container SwitchContainer; - protected readonly Container MainContentContainer; - protected readonly Box TextBackground; - protected readonly FillFlowContainer TextFlow; - - [Resolved] - protected OverlayColourProvider ColourProvider { get; private set; } = null!; - - protected const double TRANSITION_DURATION = 150; - - public const float CORNER_RADIUS = 7; - - protected const float HEIGHT = 42; - protected const float IDLE_SWITCH_WIDTH = 54; - protected const float EXPANDED_SWITCH_WIDTH = 70; - - private Colour4 activeColour; - - private readonly Bindable samplePlaybackDisabled = new BindableBool(); - private Sample? sampleOff; - private Sample? sampleOn; - public ModPanel(ModState modState) { this.modState = modState; - RelativeSizeAxes = Axes.X; - Height = 42; + Title = Mod.Name; + Description = Mod.Description; - // all below properties are applied to `Content` rather than the `ModPanel` in its entirety - // to allow external components to set these properties on the panel without affecting - // its "internal" appearance. - Content.Masking = true; - Content.CornerRadius = CORNER_RADIUS; - Content.BorderThickness = 2; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); - - Children = new Drawable[] + SwitchContainer.Child = new ModSwitchSmall(Mod) { - Background = new Box - { - RelativeSizeAxes = Axes.Both - }, - SwitchContainer = new Container - { - RelativeSizeAxes = Axes.Y, - Child = new ModSwitchSmall(Mod) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Active = { BindTarget = Active }, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Scale = new Vector2(HEIGHT / ModSwitchSmall.DEFAULT_SIZE) - } - }, - MainContentContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = CORNER_RADIUS, - Children = new Drawable[] - { - TextBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - TextFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = 17.5f, - Vertical = 4 - }, - Direction = FillDirection.Vertical, - Children = new[] - { - new OsuSpriteText - { - Text = Mod.Name, - Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Margin = new MarginPadding - { - Left = -18 * ShearedOverlayContainer.SHEAR - } - }, - new OsuSpriteText - { - Text = Mod.Description, - Font = OsuFont.Default.With(size: 12), - RelativeSizeAxes = Axes.X, - Truncate = true, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) - } - } - } - } - } - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Active = { BindTarget = Active }, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Scale = new Vector2(HEIGHT / ModSwitchSmall.DEFAULT_SIZE) }; - - Action = Active.Toggle; } public ModPanel(Mod mod) @@ -146,122 +44,33 @@ namespace osu.Game.Overlays.Mods { } - [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuColour colours, ISamplePlaybackDisabler? samplePlaybackDisabler) + [BackgroundDependencyLoader] + private void load(OsuColour colours) { - sampleOn = audio.Samples.Get(@"UI/check-on"); - sampleOff = audio.Samples.Get(@"UI/check-off"); - - activeColour = colours.ForModType(Mod.Type); - - if (samplePlaybackDisabler != null) - ((IBindable)samplePlaybackDisabled).BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); + AccentColour = colours.ForModType(Mod.Type); } - protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); - protected override void LoadComplete() { base.LoadComplete(); - Active.BindValueChanged(_ => - { - playStateChangeSamples(); - UpdateState(); - }); + Filtered.BindValueChanged(_ => updateFilterState(), true); - - UpdateState(); - FinishTransforms(true); } - private void playStateChangeSamples() + protected override void Select() { - if (samplePlaybackDisabled.Value) - return; - - if (Active.Value) - sampleOn?.Play(); - else - sampleOff?.Play(); + modState.PendingConfiguration = Mod.RequiresConfiguration; + Active.Value = true; } - protected override bool OnHover(HoverEvent e) + protected override void Deselect() { - UpdateState(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - UpdateState(); - base.OnHoverLost(e); - } - - private bool mouseDown; - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (e.Button == MouseButton.Left) - mouseDown = true; - - UpdateState(); - return false; - } - - protected override void OnMouseUp(MouseUpEvent e) - { - mouseDown = false; - - UpdateState(); - base.OnMouseUp(e); - } - - protected virtual Colour4 BackgroundColour => Active.Value ? activeColour.Darken(0.3f) : ColourProvider.Background3; - protected virtual Colour4 ForegroundColour => Active.Value ? activeColour : ColourProvider.Background2; - protected virtual Colour4 TextColour => Active.Value ? ColourProvider.Background6 : Colour4.White; - - protected virtual void UpdateState() - { - float targetWidth = Active.Value ? EXPANDED_SWITCH_WIDTH : IDLE_SWITCH_WIDTH; - double transitionDuration = TRANSITION_DURATION; - - Colour4 backgroundColour = BackgroundColour; - Colour4 foregroundColour = ForegroundColour; - Colour4 textColour = TextColour; - - // Hover affects colour of button background - if (IsHovered) - { - backgroundColour = backgroundColour.Lighten(0.1f); - foregroundColour = foregroundColour.Lighten(0.1f); - } - - // Mouse down adds a halfway tween of the movement - if (mouseDown) - { - targetWidth = (float)Interpolation.Lerp(IDLE_SWITCH_WIDTH, EXPANDED_SWITCH_WIDTH, 0.5f); - transitionDuration *= 4; - } - - Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(backgroundColour, foregroundColour), transitionDuration, Easing.OutQuint); - Background.FadeColour(backgroundColour, transitionDuration, Easing.OutQuint); - SwitchContainer.ResizeWidthTo(targetWidth, transitionDuration, Easing.OutQuint); - MainContentContainer.TransformTo(nameof(Padding), new MarginPadding - { - Left = targetWidth, - Right = CORNER_RADIUS - }, transitionDuration, Easing.OutQuint); - TextBackground.FadeColour(foregroundColour, transitionDuration, Easing.OutQuint); - TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint); + modState.PendingConfiguration = false; + Active.Value = false; } #region Filtering support - public void ApplyFilter(Func? filter) - { - Filtered.Value = filter != null && !filter.Invoke(Mod); - } - private void updateFilterState() { this.FadeTo(Filtered.Value ? 0 : 1); diff --git a/osu.Game/Overlays/Mods/ModPresetColumn.cs b/osu.Game/Overlays/Mods/ModPresetColumn.cs new file mode 100644 index 0000000000..176c527a10 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetColumn.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Localisation; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; +using Realms; + +namespace osu.Game.Overlays.Mods +{ + public class ModPresetColumn : ModSelectColumn + { + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.Orange1; + HeaderText = ModSelectOverlayStrings.PersonalPresets; + + AddPresetButton addPresetButton; + ItemsFlow.Add(addPresetButton = new AddPresetButton()); + ItemsFlow.SetLayoutPosition(addPresetButton, float.PositiveInfinity); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => rulesetChanged(), true); + } + + private IDisposable? presetSubscription; + + private void rulesetChanged() + { + presetSubscription?.Dispose(); + presetSubscription = realm.RegisterForNotifications(r => + r.All() + .Filter($"{nameof(ModPreset.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0" + + $" && {nameof(ModPreset.DeletePending)} == false", ruleset.Value.ShortName) + .OrderBy(preset => preset.Name), asyncLoadPanels); + } + + private CancellationTokenSource? cancellationTokenSource; + + private Task? latestLoadTask; + internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true; + + private void asyncLoadPanels(IRealmCollection presets, ChangeSet changes, Exception error) + { + cancellationTokenSource?.Cancel(); + + if (!presets.Any()) + { + removeAndDisposePresetPanels(); + return; + } + + latestLoadTask = LoadComponentsAsync(presets.Select(p => new ModPresetPanel(p.ToLive(realm)) + { + Shear = Vector2.Zero + }), loaded => + { + removeAndDisposePresetPanels(); + ItemsFlow.AddRange(loaded); + }, (cancellationTokenSource = new CancellationTokenSource()).Token); + + void removeAndDisposePresetPanels() + { + foreach (var panel in ItemsFlow.OfType().ToArray()) + panel.RemoveAndDisposeImmediately(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + presetSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs new file mode 100644 index 0000000000..b314a19142 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -0,0 +1,106 @@ +// 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.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + public class ModPresetPanel : ModSelectPanel, IHasCustomTooltip, IHasContextMenu + { + public readonly Live Preset; + + public override BindableBool Active { get; } = new BindableBool(); + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + private ModSettingChangeTracker? settingChangeTracker; + + public ModPresetPanel(Live preset) + { + Preset = preset; + + Title = preset.Value.Name; + Description = preset.Value.Description; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.Orange1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedMods.BindValueChanged(_ => selectedModsChanged(), true); + } + + 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 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 = Array.Empty(); + } + + private void selectedModsChanged() + { + settingChangeTracker?.Dispose(); + settingChangeTracker = new ModSettingChangeTracker(selectedMods.Value); + settingChangeTracker.SettingChanged = _ => updateActiveState(); + updateActiveState(); + } + + private void updateActiveState() + { + Active.Value = new HashSet(Preset.Value.Mods).SetEquals(selectedMods.Value); + } + + #region IHasCustomTooltip + + public ModPreset TooltipContent => Preset.Value; + public ITooltip GetCustomTooltip() => new ModPresetTooltip(ColourProvider); + + #endregion + + #region IHasContextMenu + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset))) + }; + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + settingChangeTracker?.Dispose(); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs new file mode 100644 index 0000000000..97d118fbfd --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public class ModPresetTooltip : VisibilityContainer, ITooltip + { + protected override Container Content { get; } + + private const double transition_duration = 200; + + public ModPresetTooltip(OverlayColourProvider colourProvider) + { + Width = 250; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 7; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(7), + Spacing = new Vector2(7) + } + }; + } + + private ModPreset? lastPreset; + + public void SetContent(ModPreset preset) + { + if (ReferenceEquals(preset, lastPreset)) + return; + + lastPreset = preset; + Content.ChildrenEnumerable = preset.Mods.Select(mod => new ModPresetRow(mod)); + } + + protected override void PopIn() => this.FadeIn(transition_duration, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(transition_duration, Easing.OutQuint); + + public void Move(Vector2 pos) => Position = pos; + + private class ModPresetRow : FillFlowContainer + { + public ModPresetRow(Mod mod) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + Spacing = new Vector2(4); + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7), + Children = new Drawable[] + { + new ModSwitchTiny(mod) + { + Active = { Value = true }, + Scale = new Vector2(0.6f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new OsuSpriteText + { + Text = mod.Name, + Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Margin = new MarginPadding { Bottom = 2 } + } + } + } + }; + + if (!string.IsNullOrEmpty(mod.SettingDescription)) + { + AddInternal(new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 14 }, + Text = mod.SettingDescription + }); + } + } + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSelectColumn.cs b/osu.Game/Overlays/Mods/ModSelectColumn.cs new file mode 100644 index 0000000000..d5dc079628 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSelectColumn.cs @@ -0,0 +1,180 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public abstract class ModSelectColumn : CompositeDrawable, IHasAccentColour + { + public readonly Container TopLevelContent; + + public LocalisableString HeaderText + { + set => createHeaderText(value); + } + + public Color4 AccentColour + { + get => headerBackground.Colour; + set => headerBackground.Colour = value; + } + + /// + /// Determines whether this column should accept user input. + /// + public readonly Bindable Active = new BindableBool(true); + + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; + + protected readonly Container ControlContainer; + protected readonly FillFlowContainer ItemsFlow; + + private readonly TextFlowContainer headerText; + private readonly Box headerBackground; + private readonly Container contentContainer; + private readonly Box contentBackground; + + private const float header_height = 42; + + protected ModSelectColumn() + { + Width = 320; + RelativeSizeAxes = Axes.Y; + Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + + InternalChildren = new Drawable[] + { + TopLevelContent = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModSelectPanel.CORNER_RADIUS, + Children = new Drawable[] + { + headerBackground = new Box + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModSelectPanel.CORNER_RADIUS + }, + headerText = new OsuTextFlowContainer(t => + { + t.Font = OsuFont.TorusAlternate.With(size: 17); + t.Shadow = false; + t.Colour = Colour4.Black; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Padding = new MarginPadding + { + Horizontal = 17, + Bottom = ModSelectPanel.CORNER_RADIUS + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = header_height }, + Child = contentContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + BorderThickness = 3, + Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + ControlContainer = new Container + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 14 } + } + }, + new Drawable[] + { + new OsuScrollContainer(Direction.Vertical) + { + RelativeSizeAxes = Axes.Both, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = ItemsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 7), + Padding = new MarginPadding(7) + } + } + } + } + } + } + } + } + } + } + }; + } + + private void createHeaderText(LocalisableString text) + { + headerText.Clear(); + + int wordIndex = 0; + + 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] + private void load(OverlayColourProvider colourProvider) + { + contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3); + contentBackground.Colour = colourProvider.Background4; + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 04c424461e..ccc075b190 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -11,12 +11,14 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -72,6 +74,11 @@ namespace osu.Game.Overlays.Mods /// protected virtual bool AllowCustomisation => true; + /// + /// Whether the column with available mod presets should be shown. + /// + protected virtual bool ShowPresets => false; + protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false); protected virtual IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) => newSelection; @@ -80,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 } @@ -100,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; @@ -139,40 +146,37 @@ namespace osu.Game.Overlays.Mods MainAreaContent.AddRange(new Drawable[] { - new Container + new OsuContextMenuContainer { - Padding = new MarginPadding - { - Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING, - Bottom = PADDING - }, RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Children = new Drawable[] + Child = new PopoverContainer { - columnScroll = new ColumnScrollContainer + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - Masking = false, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = columnFlow = new ColumnFlowContainer + Top = (ShowTotalMultiplier ? ModsEffectDisplay.HEIGHT : 0) + PADDING, + Bottom = PADDING + }, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Children = new Drawable[] + { + columnScroll = new ColumnScrollContainer { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Shear = new Vector2(SHEAR, 0), - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Margin = new MarginPadding { Horizontal = 70 }, - Padding = new MarginPadding { Bottom = 10 }, - Children = new[] + RelativeSizeAxes = Axes.Both, + Masking = false, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = columnFlow = new ColumnFlowContainer { - createModColumnContent(ModType.DifficultyReduction), - createModColumnContent(ModType.DifficultyIncrease), - createModColumnContent(ModType.Automation), - createModColumnContent(ModType.Conversion), - createModColumnContent(ModType.Fun) + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Shear = new Vector2(SHEAR, 0), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 70 }, + Padding = new MarginPadding { Bottom = 10 }, + ChildrenEnumerable = createColumns() } } } @@ -187,7 +191,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.X, - Height = DifficultyMultiplierDisplay.HEIGHT, + Height = ModsEffectDisplay.HEIGHT, Margin = new MarginPadding { Horizontal = 100 }, Child = multiplierDisplay = new DifficultyMultiplierDisplay { @@ -210,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, @@ -222,6 +226,8 @@ namespace osu.Game.Overlays.Mods globalAvailableMods.BindTo(game.AvailableMods); } + private ModSettingChangeTracker? modSettingChangeTracker; + protected override void LoadComplete() { // this is called before base call so that the mod state is populated early, and the transition in `PopIn()` can play out properly. @@ -238,9 +244,17 @@ namespace osu.Game.Overlays.Mods SelectedMods.BindValueChanged(val => { + modSettingChangeTracker?.Dispose(); + updateMultiplier(); - updateCustomisation(val); updateFromExternalSelection(); + updateCustomisation(); + + if (AllowCustomisation) + { + modSettingChangeTracker = new ModSettingChangeTracker(val.NewValue); + modSettingChangeTracker.SettingChanged += _ => updateMultiplier(); + } }, true); customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); @@ -259,7 +273,7 @@ namespace osu.Game.Overlays.Mods /// public void SelectAll() { - foreach (var column in columnFlow.Columns) + foreach (var column in columnFlow.Columns.OfType()) column.SelectAll(); } @@ -268,10 +282,27 @@ namespace osu.Game.Overlays.Mods /// public void DeselectAll() { - foreach (var column in columnFlow.Columns) + foreach (var column in columnFlow.Columns.OfType()) column.DeselectAll(); } + private IEnumerable createColumns() + { + if (ShowPresets) + { + yield return new ColumnDimContainer(new ModPresetColumn + { + Margin = new MarginPadding { Right = 10 } + }); + } + + yield return createModColumnContent(ModType.DifficultyReduction); + yield return createModColumnContent(ModType.DifficultyIncrease); + yield return createModColumnContent(ModType.Automation); + yield return createModColumnContent(ModType.Conversion); + yield return createModColumnContent(ModType.Fun); + } + private ColumnDimContainer createModColumnContent(ModType modType) { var column = CreateModColumn(modType).With(column => @@ -280,12 +311,7 @@ namespace osu.Game.Overlays.Mods column.Margin = new MarginPadding { Right = 10 }; }); - return new ColumnDimContainer(column) - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140), - }; + return new ColumnDimContainer(column); } private void createLocalMods() @@ -307,7 +333,7 @@ namespace osu.Game.Overlays.Mods AvailableMods.Value = newLocalAvailableMods; filterMods(); - foreach (var column in columnFlow.Columns) + foreach (var column in columnFlow.Columns.OfType()) column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty()); } @@ -330,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 @@ -368,7 +395,7 @@ namespace osu.Game.Overlays.Mods foreach (var button in footerButtonFlow) { - if (button != customisationButton) + if (button != CustomisationButton) button.Enabled.Value = !customisationVisible.Value; } @@ -448,7 +475,7 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value); + bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => modState.Filtered.Value); double delay = allFiltered ? 0 : nonFilteredColumnCount * 30; double duration = allFiltered ? 0 : fade_in_duration; @@ -506,14 +533,19 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value); + bool allFiltered = false; + + if (column is ModColumn modColumn) + { + allFiltered = modColumn.AvailableMods.All(modState => modState.Filtered.Value); + modColumn.FlushPendingSelections(); + } double duration = allFiltered ? 0 : fade_out_duration; float newYPosition = 0; if (!allFiltered) newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; - column.FlushPendingSelections(); column.TopLevelContent .MoveToY(newYPosition, duration, Easing.OutQuint) .FadeOut(duration, Easing.OutQuint); @@ -556,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(); } } @@ -579,6 +611,7 @@ namespace osu.Game.Overlays.Mods /// /// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility. /// + [Cached] internal class ColumnScrollContainer : OsuScrollContainer { public ColumnScrollContainer() @@ -622,7 +655,7 @@ namespace osu.Game.Overlays.Mods /// internal class ColumnFlowContainer : FillFlowContainer { - public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); + public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); public override void Add(ColumnDimContainer dimContainer) { @@ -638,7 +671,7 @@ namespace osu.Game.Overlays.Mods /// internal class ColumnDimContainer : Container { - public ModColumn Column { get; } + public ModSelectColumn Column { get; } /// /// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen. @@ -653,12 +686,21 @@ namespace osu.Game.Overlays.Mods [Resolved] private OsuColour colours { get; set; } = null!; - public ColumnDimContainer(ModColumn column) + public ColumnDimContainer(ModSelectColumn column) { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + Child = Column = column; column.Active.BindTo(Active); } + [BackgroundDependencyLoader] + private void load(ColumnScrollContainer columnScroll) + { + RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -667,7 +709,18 @@ namespace osu.Game.Overlays.Mods FinishTransforms(); } - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || Column.SelectionAnimationRunning; + 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 new file mode 100644 index 0000000000..27abface0c --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSelectPanel.cs @@ -0,0 +1,267 @@ +// 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.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Overlays.Mods +{ + public abstract class ModSelectPanel : OsuClickableContainer, IHasAccentColour + { + public abstract BindableBool Active { get; } + + public Color4 AccentColour { get; set; } + + public LocalisableString Title + { + get => titleText.Text; + set => titleText.Text = value; + } + + public LocalisableString Description + { + get => descriptionText.Text; + set => descriptionText.Text = value; + } + + public const float CORNER_RADIUS = 7; + public const float HEIGHT = 42; + + protected virtual float IdleSwitchWidth => 14; + protected virtual float ExpandedSwitchWidth => 30; + protected virtual Colour4 BackgroundColour => Active.Value ? AccentColour.Darken(0.3f) : ColourProvider.Background3; + protected virtual Colour4 ForegroundColour => Active.Value ? AccentColour : ColourProvider.Background2; + protected virtual Colour4 TextColour => Active.Value ? ColourProvider.Background6 : Colour4.White; + + protected const double TRANSITION_DURATION = 150; + + protected readonly Box Background; + protected readonly Container SwitchContainer; + protected readonly Container MainContentContainer; + protected readonly Box TextBackground; + protected readonly FillFlowContainer TextFlow; + + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } = null!; + + private readonly OsuSpriteText titleText; + private readonly OsuSpriteText descriptionText; + + private readonly Bindable samplePlaybackDisabled = new BindableBool(); + private Sample? sampleOff; + private Sample? sampleOn; + + protected ModSelectPanel() + { + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + + // all below properties are applied to `Content` rather than the `ModPanel` in its entirety + // to allow external components to set these properties on the panel without affecting + // its "internal" appearance. + Content.Masking = true; + Content.CornerRadius = CORNER_RADIUS; + Content.BorderThickness = 2; + + Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + + Children = new Drawable[] + { + Background = new Box + { + RelativeSizeAxes = Axes.Both + }, + SwitchContainer = new Container + { + RelativeSizeAxes = Axes.Y, + }, + MainContentContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + TextBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + TextFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 17.5f, + Vertical = 4 + }, + Direction = FillDirection.Vertical, + Children = new[] + { + titleText = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Margin = new MarginPadding + { + Left = -18 * ShearedOverlayContainer.SHEAR + } + }, + descriptionText = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 12), + RelativeSizeAxes = Axes.X, + Truncate = true, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) + } + } + } + } + } + } + }; + + 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) + { + sampleOn = audio.Samples.Get(@"UI/check-on"); + sampleOff = audio.Samples.Get(@"UI/check-off"); + + if (samplePlaybackDisabler != null) + ((IBindable)samplePlaybackDisabled).BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); + } + + protected sealed override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); + + protected override void LoadComplete() + { + base.LoadComplete(); + Active.BindValueChanged(_ => + { + playStateChangeSamples(); + UpdateState(); + }); + + UpdateState(); + FinishTransforms(true); + } + + private void playStateChangeSamples() + { + if (samplePlaybackDisabled.Value) + return; + + if (Active.Value) + sampleOn?.Play(); + else + sampleOff?.Play(); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + UpdateState(); + base.OnHoverLost(e); + } + + private bool mouseDown; + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + mouseDown = true; + + UpdateState(); + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + mouseDown = false; + + UpdateState(); + base.OnMouseUp(e); + } + + protected virtual void UpdateState() + { + float targetWidth = Active.Value ? ExpandedSwitchWidth : IdleSwitchWidth; + double transitionDuration = TRANSITION_DURATION; + + Colour4 backgroundColour = BackgroundColour; + Colour4 foregroundColour = ForegroundColour; + Colour4 textColour = TextColour; + + // Hover affects colour of button background + if (IsHovered) + { + backgroundColour = backgroundColour.Lighten(0.1f); + foregroundColour = foregroundColour.Lighten(0.1f); + } + + // Mouse down adds a halfway tween of the movement + if (mouseDown) + { + targetWidth = (float)Interpolation.Lerp(IdleSwitchWidth, ExpandedSwitchWidth, 0.5f); + transitionDuration *= 4; + } + + Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(backgroundColour, foregroundColour), transitionDuration, Easing.OutQuint); + Background.FadeColour(backgroundColour, transitionDuration, Easing.OutQuint); + SwitchContainer.ResizeWidthTo(targetWidth, transitionDuration, Easing.OutQuint); + MainContentContainer.TransformTo(nameof(Padding), new MarginPadding + { + Left = targetWidth, + Right = CORNER_RADIUS + }, transitionDuration, Easing.OutQuint); + TextBackground.FadeColour(foregroundColour, transitionDuration, Easing.OutQuint); + TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint); + } + } +} 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/Mods/ModsEffectDisplay.cs b/osu.Game/Overlays/Mods/ModsEffectDisplay.cs new file mode 100644 index 0000000000..eed3181e49 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModsEffectDisplay.cs @@ -0,0 +1,223 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + /// + /// Base class for displays of mods effects. + /// + public abstract class ModsEffectDisplay : Container, IHasCurrentValue + { + public const float HEIGHT = 42; + private const float transition_duration = 200; + + private readonly Box contentBackground; + private readonly Box labelBackground; + private readonly FillFlowContainer content; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + /// + /// Text to display in the left area of the display. + /// + protected abstract LocalisableString Label { get; } + + protected virtual float ValueAreaWidth => 56; + + protected virtual string CounterFormat => @"N0"; + + protected override Container Content => content; + + protected readonly RollingCounter Counter; + + protected ModsEffectDisplay() + { + Height = HEIGHT; + AutoSizeAxes = Axes.X; + + InternalChild = new InputBlockingContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0), + Children = new Drawable[] + { + contentBackground = new Box + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + Width = ValueAreaWidth + ModSelectPanel.CORNER_RADIUS + }, + new GridContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, ValueAreaWidth) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + Children = new Drawable[] + { + labelBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 18 }, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Text = Label, + Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) + } + } + }, + content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Spacing = new Vector2(2, 0), + Child = Counter = new EffectCounter(CounterFormat) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { BindTarget = Current } + } + } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + labelBackground.Colour = colourProvider.Background4; + } + + protected override void LoadComplete() + { + Current.BindValueChanged(e => + { + var effect = CalculateEffectForComparison(e.NewValue.CompareTo(Current.Default)); + setColours(effect); + }, true); + } + + /// + /// Fades colours of text and its background according to displayed value. + /// + /// Effect of the value. + private void setColours(ModEffect effect) + { + switch (effect) + { + case ModEffect.NotChanged: + contentBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint); + content.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + break; + + case ModEffect.DifficultyReduction: + contentBackground.FadeColour(colours.ForModType(ModType.DifficultyReduction), transition_duration, Easing.OutQuint); + content.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); + break; + + case ModEffect.DifficultyIncrease: + contentBackground.FadeColour(colours.ForModType(ModType.DifficultyIncrease), transition_duration, Easing.OutQuint); + content.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(effect)); + } + } + + /// + /// Converts signed integer into . Negative values are counted as difficulty reduction, positive as increase. + /// + /// Value to convert. Will arrive from comparison between bindable once it changes and it's . + /// Effect of the value. + protected virtual ModEffect CalculateEffectForComparison(int comparison) + { + if (comparison == 0) + return ModEffect.NotChanged; + if (comparison < 0) + return ModEffect.DifficultyReduction; + + return ModEffect.DifficultyIncrease; + } + + protected enum ModEffect + { + NotChanged, + DifficultyReduction, + DifficultyIncrease + } + + private class EffectCounter : RollingCounter + { + private readonly string? format; + + public EffectCounter(string? format) + { + this.format = format; + } + + protected override double RollingDuration => 500; + + protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(format); + + protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText + { + Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) + }; + } + } +} diff --git a/osu.Game/Overlays/Music/FilterControl.cs b/osu.Game/Overlays/Music/FilterControl.cs index eb12a62864..ffa50c3a35 100644 --- a/osu.Game/Overlays/Music/FilterControl.cs +++ b/osu.Game/Overlays/Music/FilterControl.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Music public Action FilterChanged; public readonly FilterTextBox Search; - private readonly CollectionDropdown collectionDropdown; + private readonly NowPlayingCollectionDropdown collectionDropdown; public FilterControl() { @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Music RelativeSizeAxes = Axes.X, Height = 40, }, - collectionDropdown = new CollectionDropdown { RelativeSizeAxes = Axes.X } + collectionDropdown = new NowPlayingCollectionDropdown { RelativeSizeAxes = Axes.X } }, }, }; diff --git a/osu.Game/Overlays/Music/FilterCriteria.cs b/osu.Game/Overlays/Music/FilterCriteria.cs index f435c4e6e4..ad491be845 100644 --- a/osu.Game/Overlays/Music/FilterCriteria.cs +++ b/osu.Game/Overlays/Music/FilterCriteria.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using osu.Game.Collections; +using osu.Game.Database; namespace osu.Game.Overlays.Music { @@ -19,6 +20,6 @@ namespace osu.Game.Overlays.Music /// The collection to filter beatmaps from. /// [CanBeNull] - public BeatmapCollection Collection; + public Live Collection; } } diff --git a/osu.Game/Overlays/Music/CollectionDropdown.cs b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs similarity index 93% rename from osu.Game/Overlays/Music/CollectionDropdown.cs rename to osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs index c1ba16788e..635a2e5044 100644 --- a/osu.Game/Overlays/Music/CollectionDropdown.cs +++ b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs @@ -15,9 +15,9 @@ using osu.Game.Graphics; namespace osu.Game.Overlays.Music { /// - /// A for use in the . + /// A for use in the . /// - public class CollectionDropdown : CollectionFilterDropdown + public class NowPlayingCollectionDropdown : CollectionDropdown { protected override bool ShowManageCollectionsItem => false; diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs index 954c4de493..b48257a61a 100644 --- a/osu.Game/Overlays/Music/Playlist.cs +++ b/osu.Game/Overlays/Music/Playlist.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; using System.Linq; using osu.Framework.Bindables; @@ -17,41 +15,47 @@ namespace osu.Game.Overlays.Music { public class Playlist : OsuRearrangeableListContainer> { - public Action> RequestSelection; + public Action>? RequestSelection; public readonly Bindable> SelectedSet = new Bindable>(); + private FilterCriteria currentCriteria = new FilterCriteria(); + public new MarginPadding Padding { get => base.Padding; set => base.Padding = value; } + protected override void OnItemsChanged() + { + base.OnItemsChanged(); + Filter(currentCriteria); + } + public void Filter(FilterCriteria criteria) { var items = (SearchContainer>>)ListContainer; + string[]? currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray()); + foreach (var item in items.OfType()) { - if (criteria.Collection == null) - item.InSelectedCollection = true; - else - { - item.InSelectedCollection = item.Model.Value.Beatmaps.Select(b => b.MD5Hash) - .Any(criteria.Collection.BeatmapHashes.Contains); - } + item.InSelectedCollection = currentCollectionHashes == null || item.Model.Value.Beatmaps.Select(b => b.MD5Hash).Any(currentCollectionHashes.Contains); } items.SearchTerm = criteria.SearchText; + currentCriteria = criteria; } - public Live FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); + public Live? FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); - protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => new PlaylistItem(item) - { - SelectedSet = { BindTarget = SelectedSet }, - RequestSelection = set => RequestSelection?.Invoke(set) - }; + protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => + new PlaylistItem(item) + { + SelectedSet = { BindTarget = SelectedSet }, + RequestSelection = set => RequestSelection?.Invoke(set) + }; protected override FillFlowContainer>> CreateListFillFlowContainer() => new SearchContainer>> { diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index e33fc8064f..63f1aa248c 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -24,9 +25,7 @@ namespace osu.Game.Overlays.Music public class PlaylistOverlay : VisibilityContainer { private const float transition_duration = 600; - private const float playlist_height = 510; - - public IBindableList> BeatmapSets => beatmapSets; + public const float PLAYLIST_HEIGHT = 510; private readonly BindableList> beatmapSets = new BindableList>(); @@ -104,9 +103,7 @@ namespace osu.Game.Overlays.Music { base.LoadComplete(); - // tests might bind externally, in which case we don't want to involve realm. - if (beatmapSets.Count == 0) - beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged); + beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged); list.Items.BindTo(beatmapSets); beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true); @@ -134,7 +131,7 @@ namespace osu.Game.Overlays.Music filter.Search.HoldFocus = true; Schedule(() => filter.Search.TakeFocus()); - this.ResizeTo(new Vector2(1, playlist_height), transition_duration, Easing.OutQuint); + this.ResizeTo(new Vector2(1, RelativeSizeAxes.HasFlagFast(Axes.Y) ? 1f : PLAYLIST_HEIGHT), transition_duration, Easing.OutQuint); this.FadeIn(transition_duration, Easing.OutQuint); } diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 8af295dfe8..793b7e294f 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -58,19 +58,22 @@ namespace osu.Game.Overlays [Resolved] private RealmAccess realm { get; set; } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - // Todo: These binds really shouldn't be here, but are unlikely to cause any issues for now. - // They are placed here for now since some tests rely on setting the beatmap _and_ their hierarchies inside their load(), which runs before the MusicController's load(). - beatmap.BindValueChanged(beatmapChanged, true); + base.LoadComplete(); + + beatmap.BindValueChanged(b => changeBeatmap(b.NewValue), true); mods.BindValueChanged(_ => ResetTrackAdjustments(), true); } /// /// Forcefully reload the current 's track from disk. /// - public void ReloadCurrentTrack() => changeTrack(); + public void ReloadCurrentTrack() + { + changeTrack(); + TrackChanged?.Invoke(current, TrackChangeDirection.None); + } /// /// Returns whether the beatmap track is playing. @@ -259,8 +262,6 @@ namespace osu.Game.Overlays private IQueryable getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending); - private void beatmapChanged(ValueChangedEvent beatmap) => changeBeatmap(beatmap.NewValue); - private void changeBeatmap(WorkingBeatmap newWorking) { // This method can potentially be triggered multiple times as it is eagerly fired in next() / prev() to ensure correct execution order diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index 281077bcf6..36548c893c 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -12,10 +12,10 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Threading; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Overlays.Notifications; using osu.Game.Resources.Localisation.Web; +using osuTK; using NotificationsStrings = osu.Game.Localisation.NotificationsStrings; namespace osu.Game.Overlays @@ -35,10 +35,28 @@ namespace osu.Game.Overlays [Resolved] private AudioManager audio { get; set; } = null!; - private readonly IBindable firstRunSetupVisibility = new Bindable(); + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (State.Value == Visibility.Visible) + return base.ReceivePositionalInputAt(screenSpacePos); + + if (toastTray.IsDisplayingToasts) + return toastTray.ReceivePositionalInputAt(screenSpacePos); + + return false; + } + + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree || toastTray.IsDisplayingToasts; + + private NotificationOverlayToastTray toastTray = null!; + + private Container mainContent = null!; [BackgroundDependencyLoader] - private void load(FirstRunSetupOverlay? firstRunSetup) + private void load() { X = WIDTH; Width = WIDTH; @@ -46,55 +64,61 @@ namespace osu.Game.Overlays Children = new Drawable[] { - new Box + toastTray = new NotificationOverlayToastTray { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.05f), + ForwardNotificationToPermanentStore = addPermanently, + Origin = Anchor.TopRight, }, - new OsuScrollContainer + mainContent = new Container { - Masking = true, RelativeSizeAxes = Axes.Both, - Children = new[] + Children = new Drawable[] { - sections = new FillFlowContainer + new Box { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new OsuScrollContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, Children = new[] { - new NotificationSection(AccountsStrings.NotificationsTitle, "Clear All") + sections = new FillFlowContainer { - AcceptTypes = new[] { typeof(SimpleNotification) } - }, - new NotificationSection(@"Running Tasks", @"Cancel All") - { - AcceptTypes = new[] { typeof(ProgressNotification) } + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Children = new[] + { + new NotificationSection(AccountsStrings.NotificationsTitle, new[] { typeof(SimpleNotification) }, "Clear All"), + new NotificationSection(@"Running Tasks", new[] { typeof(ProgressNotification) }, @"Cancel All"), + } } } } } - } + }, }; - - if (firstRunSetup != null) - firstRunSetupVisibility.BindTo(firstRunSetup.State); } private ScheduledDelegate? notificationsEnabler; private void updateProcessingMode() { - bool enabled = (OverlayActivationMode.Value == OverlayActivation.All && firstRunSetupVisibility.Value != Visibility.Visible) || State.Value == Visibility.Visible; + bool enabled = OverlayActivationMode.Value == OverlayActivation.All || State.Value == Visibility.Visible; notificationsEnabler?.Cancel(); if (enabled) // we want a slight delay before toggling notifications on to avoid the user becoming overwhelmed. - notificationsEnabler = Scheduler.AddDelayed(() => processingPosts = true, State.Value == Visibility.Visible ? 0 : 1000); + notificationsEnabler = Scheduler.AddDelayed(() => processingPosts = true, State.Value == Visibility.Visible ? 0 : 250); else + { processingPosts = false; + toastTray.FlushAllToasts(); + } } protected override void LoadComplete() @@ -102,19 +126,22 @@ namespace osu.Game.Overlays base.LoadComplete(); State.BindValueChanged(_ => updateProcessingMode()); - firstRunSetupVisibility.BindValueChanged(_ => updateProcessingMode()); OverlayActivationMode.BindValueChanged(_ => updateProcessingMode(), true); } public IBindable UnreadCount => unreadCount; + public int ToastCount => toastTray.UnreadCount; + private readonly BindableInt unreadCount = new BindableInt(); private int runningDepth; private readonly Scheduler postScheduler = new Scheduler(); - public override bool IsPresent => base.IsPresent || postScheduler.HasPendingTasks; + public override bool IsPresent => + // Delegate presence as we need to consider the toast tray in addition to the main overlay. + State.Value == Visibility.Visible || mainContent.IsPresent || toastTray.IsPresent || postScheduler.HasPendingTasks; private bool processingPosts = true; @@ -131,18 +158,33 @@ namespace osu.Game.Overlays if (notification is IHasCompletionTarget hasCompletionTarget) hasCompletionTarget.CompletionTarget = Post; - var ourType = notification.GetType(); + playDebouncedSample(notification.PopInSampleName); - var section = sections.Children.FirstOrDefault(s => s.AcceptTypes.Any(accept => accept.IsAssignableFrom(ourType))); - section?.Add(notification, notification.DisplayOnTop ? -runningDepth : runningDepth); - - if (notification.IsImportant) - Show(); + if (State.Value == Visibility.Hidden) + { + notification.IsInToastTray = true; + toastTray.Post(notification); + } + else + addPermanently(notification); updateCounts(); - playDebouncedSample(notification.PopInSampleName); }); + private void addPermanently(Notification notification) + { + notification.IsInToastTray = false; + + var ourType = notification.GetType(); + int depth = notification.DisplayOnTop ? -runningDepth : runningDepth; + + var section = sections.Children.First(s => s.AcceptedNotificationTypes.Any(accept => accept.IsAssignableFrom(ourType))); + + section.Add(notification, depth); + + updateCounts(); + } + protected override void Update() { base.Update(); @@ -156,7 +198,9 @@ namespace osu.Game.Overlays base.PopIn(); this.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); - this.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + + toastTray.FlushAllToasts(); } protected override void PopOut() @@ -166,17 +210,17 @@ namespace osu.Game.Overlays markAllRead(); this.MoveToX(WIDTH, TRANSITION_LENGTH, Easing.OutQuint); - this.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); } - private void notificationClosed() + private void notificationClosed() => Schedule(() => { updateCounts(); // this debounce is currently shared between popin/popout sounds, which means one could potentially not play when the user is expecting it. // popout is constant across all notification types, and should therefore be handled using playback concurrency instead, but seems broken at the moment. playDebouncedSample("UI/overlay-pop-out"); - } + }); private void playDebouncedSample(string sampleName) { @@ -187,16 +231,16 @@ namespace osu.Game.Overlays } } - private void updateCounts() - { - unreadCount.Value = sections.Select(c => c.UnreadCount).Sum(); - } - private void markAllRead() { sections.Children.ForEach(s => s.MarkAllRead()); - + toastTray.MarkAllRead(); updateCounts(); } + + private void updateCounts() + { + unreadCount.Value = sections.Select(c => c.UnreadCount).Sum() + toastTray.UnreadCount; + } } } diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs new file mode 100644 index 0000000000..329379de4a --- /dev/null +++ b/osu.Game/Overlays/NotificationOverlayToastTray.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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Overlays.Notifications; +using osuTK; + +namespace osu.Game.Overlays +{ + /// + /// A tray which attaches to the left of to show temporary toasts. + /// + public class NotificationOverlayToastTray : CompositeDrawable + { + public override bool IsPresent => toastContentBackground.Height > 0 || toastFlow.Count > 0; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => toastFlow.ReceivePositionalInputAt(screenSpacePos); + + public bool IsDisplayingToasts => toastFlow.Count > 0; + + private FillFlowContainer toastFlow = null!; + private BufferedContainer toastContentBackground = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public Action? ForwardNotificationToPermanentStore { get; set; } + + public int UnreadCount => allDisplayedNotifications.Count(n => !n.WasClosed && !n.Read); + + /// + /// Notifications contained in the toast flow, or in a detached state while they animate during forwarding to the main overlay. + /// + private IEnumerable allDisplayedNotifications => toastFlow.Concat(InternalChildren.OfType()); + + private int runningDepth; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding(20); + + InternalChildren = new Drawable[] + { + toastContentBackground = (new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = ColourInfo.GradientVertical( + colourProvider.Background6.Opacity(0.7f), + colourProvider.Background6.Opacity(0.5f)), + RelativeSizeAxes = Axes.Both, + Height = 0, + }.WithEffect(new BlurEffect + { + PadExtent = true, + Sigma = new Vector2(20), + }).With(postEffectDrawable => + { + postEffectDrawable.Scale = new Vector2(1.5f, 1); + postEffectDrawable.Position += new Vector2(70, -50); + postEffectDrawable.AutoSizeAxes = Axes.None; + postEffectDrawable.RelativeSizeAxes = Axes.X; + })), + toastFlow = new FillFlowContainer + { + LayoutDuration = 150, + LayoutEasing = Easing.OutQuart, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }; + } + + public void MarkAllRead() + { + toastFlow.Children.ForEach(n => n.Read = true); + InternalChildren.OfType().ForEach(n => n.Read = true); + } + + public void FlushAllToasts() + { + foreach (var notification in toastFlow.ToArray()) + forwardNotification(notification); + } + + public void Post(Notification notification) + { + ++runningDepth; + + notification.ForwardToOverlay = () => forwardNotification(notification); + + int depth = notification.DisplayOnTop ? -runningDepth : runningDepth; + + toastFlow.Insert(depth, notification); + + scheduleDismissal(); + + void scheduleDismissal() => Scheduler.AddDelayed(() => + { + // Notification dismissed by user. + if (notification.WasClosed) + return; + + // Notification forwarded away. + if (notification.Parent != toastFlow) + return; + + // Notification hovered; delay dismissal. + if (notification.IsHovered || notification.IsDragged) + { + scheduleDismissal(); + return; + } + + // All looks good, forward away! + forwardNotification(notification); + }, notification.IsImportant ? 12000 : 2500); + } + + private void forwardNotification(Notification notification) + { + if (!notification.IsInToastTray) + return; + + Debug.Assert(notification.Parent == toastFlow); + + // Temporarily remove from flow so we can animate the position off to the right. + toastFlow.Remove(notification, false); + AddInternal(notification); + + notification.MoveToOffset(new Vector2(400, 0), NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint); + notification.FadeOut(NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint).OnComplete(_ => + { + RemoveInternal(notification, false); + ForwardNotificationToPermanentStore?.Invoke(notification); + + notification.FadeIn(300, Easing.OutQuint); + }); + } + + protected override void Update() + { + base.Update(); + + float height = toastFlow.Count > 0 ? toastFlow.DrawHeight + 120 : 0; + float alpha = toastFlow.Count > 0 ? MathHelper.Clamp(toastFlow.DrawHeight / 41, 0, 1) * toastFlow.Children.Max(n => n.Alpha) : 0; + + toastContentBackground.Height = (float)Interpolation.DampContinuously(toastContentBackground.Height, height, 10, Clock.ElapsedFrameTime); + toastContentBackground.Alpha = (float)Interpolation.DampContinuously(toastContentBackground.Alpha, alpha, 10, Clock.ElapsedFrameTime); + } + } +} diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 64bf3693c8..8be9d2072b 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -1,32 +1,35 @@ // 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 osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Overlays.Notifications { public abstract class Notification : Container { /// - /// User requested close. + /// Notification was closed, either by user or otherwise. + /// Importantly, this event may be fired from a non-update thread. /// - public event Action Closed; + public event Action? Closed; public abstract LocalisableString Text { get; set; } @@ -38,7 +41,9 @@ namespace osu.Game.Overlays.Notifications /// /// Run on user activating the notification. Return true to close. /// - public Func Activated; + public Func? Activated; + + public Action? ForwardToOverlay { get; set; } /// /// Should we show at the top of our section on display? @@ -48,32 +53,81 @@ namespace osu.Game.Overlays.Notifications public virtual string PopInSampleName => "UI/notification-pop-in"; protected NotificationLight Light; - private readonly CloseButton closeButton; + protected Container IconContent; + + public bool WasClosed { get; private set; } + private readonly Container content; protected override Container Content => content; - protected Container NotificationContent; + protected Container MainContent; + + private readonly DragContainer dragContainer; public virtual bool Read { get; set; } + protected virtual bool AllowFlingDismiss => true; + + public new bool IsDragged => dragContainer.IsDragged; + + protected virtual IconUsage CloseButtonIcon => FontAwesome.Solid.Check; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && !WasClosed; + + private bool isInToastTray; + + /// + /// Whether this notification is in the . + /// + public bool IsInToastTray + { + get => isInToastTray; + set + { + isInToastTray = value; + + if (!isInToastTray) + { + dragContainer.ResetPosition(); + if (!Read) + Light.FadeIn(100); + } + } + } + + private readonly Box initialFlash; + + private Box background = null!; + protected Notification() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - AddRangeInternal(new Drawable[] + InternalChildren = new Drawable[] { Light = new NotificationLight { + Alpha = 0, Margin = new MarginPadding { Right = 5 }, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreRight, }, - NotificationContent = new Container + dragContainer = new DragContainer(this) { - CornerRadius = 8, + // Use margin instead of FillFlow spacing to fix extra padding appearing when notification shrinks + // in height. + Padding = new MarginPadding { Vertical = 3f }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }.WithChild(MainContent = new Container + { + CornerRadius = 6, Masking = true, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -81,69 +135,106 @@ namespace osu.Game.Overlays.Notifications AutoSizeEasing = Easing.OutQuint, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - new Container + new GridContainer { RelativeSizeAxes = Axes.X, - Padding = new MarginPadding(5), AutoSizeAxes = Axes.Y, - Children = new Drawable[] + RowDimensions = new[] { - IconContent = new Container - { - Size = new Vector2(40), - Masking = true, - CornerRadius = 5, - }, - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Left = 45, - Right = 30 - }, - } - } - }, - closeButton = new CloseButton - { - Alpha = 0, - Action = Close, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding - { - Right = 5 + new Dimension(GridSizeMode.AutoSize, minSize: 60) }, - } + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + IconContent = new Container + { + Width = 40, + RelativeSizeAxes = Axes.Y, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + content = new Container + { + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }, + new CloseButton(CloseButtonIcon) + { + Action = () => Close(true), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + } + } + }, + }, + initialFlash = new Box + { + Colour = Color4.White.Opacity(0.8f), + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + }, } - } + }) + }; + } + + [BackgroundDependencyLoader] + private void load() + { + MainContent.Add(background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + Depth = float.MaxValue }); } protected override bool OnHover(HoverEvent e) { - closeButton.FadeIn(75); + background.FadeColour(colourProvider.Background2, 200, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - closeButton.FadeOut(75); + background.FadeColour(colourProvider.Background3, 200, Easing.OutQuint); base.OnHoverLost(e); } + protected override bool OnMouseDown(MouseDownEvent e) + { + // right click doesn't trigger OnClick so we need to handle here until that changes. + if (e.Button != MouseButton.Left) + { + Close(true); + return true; + } + + return base.OnMouseDown(e); + } + protected override bool OnClick(ClickEvent e) { - if (Activated?.Invoke() ?? true) - Close(); + // Clicking with anything but left button should dismiss but not perform the activation action. + if (e.Button == MouseButton.Left && Activated?.Invoke() == false) + return true; + Close(false); return true; } @@ -152,59 +243,195 @@ namespace osu.Game.Overlays.Notifications base.LoadComplete(); this.FadeInFromZero(200); - NotificationContent.MoveToX(DrawSize.X); - NotificationContent.MoveToX(0, 500, Easing.OutQuint); + + MainContent.MoveToX(DrawSize.X); + MainContent.MoveToX(0, 500, Easing.OutQuint); + + initialFlash.FadeOutFromOne(2000, Easing.OutQuart); } - public bool WasClosed; - - public virtual void Close() + public virtual void Close(bool runFlingAnimation) { if (WasClosed) return; WasClosed = true; Closed?.Invoke(); - this.FadeOut(100); - Expire(); + + Schedule(() => + { + if (runFlingAnimation && dragContainer.FlingLeft()) + this.FadeOut(600, Easing.In); + else + this.FadeOut(100); + + Expire(); + }); } - private class CloseButton : OsuClickableContainer + private class DragContainer : Container { - private Color4 hoverColour; + private Vector2 velocity; + private Vector2 lastPosition; - public CloseButton() + private readonly Notification notification; + + public DragContainer(Notification notification) { - Colour = OsuColour.Gray(0.2f); - AutoSizeAxes = Axes.Both; + this.notification = notification; + } - Children = new[] + public override RectangleF BoundingBox + { + get { - new SpriteIcon + var childBounding = Children.First().BoundingBox; + + if (X < 0) childBounding *= new Vector2(1, Math.Max(0, 1 + (X / 300))); + if (Y > 0) childBounding *= new Vector2(1, Math.Max(0, 1 - (Y / 200))); + + return childBounding; + } + } + + protected override bool OnDragStart(DragStartEvent e) => notification.IsInToastTray; + + protected override void OnDrag(DragEvent e) + { + if (!notification.IsInToastTray) + return; + + Vector2 change = e.MousePosition - e.MouseDownPosition; + + // Diminish the drag distance as we go further to simulate "rubber band" feeling. + change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.8f) / change.Length; + + // Only apply Y change if dragging to the left. + if (change.X >= 0) + change.Y = 0; + else + change.Y *= (float)Interpolation.ApplyEasing(Easing.InOutQuart, Math.Min(1, -change.X / 200)); + + this.MoveTo(change); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (notification.AllowFlingDismiss && (Rotation < -10 || velocity.X < -0.3f)) + notification.Close(true); + else if (X > 30 || velocity.X > 0.3f) + notification.ForwardToOverlay?.Invoke(); + else + ResetPosition(); + + base.OnDragEnd(e); + } + + private bool flinging; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + Rotation = Math.Min(0, X * 0.1f); + + if (flinging) + { + velocity.Y += (float)Clock.ElapsedFrameTime * 0.005f; + Position += (float)Clock.ElapsedFrameTime * velocity; + } + else if (Clock.ElapsedFrameTime > 0) + { + Vector2 change = (Position - lastPosition) / (float)Clock.ElapsedFrameTime; + + if (velocity.X == 0) + velocity = change; + else + { + velocity = new Vector2( + (float)Interpolation.DampContinuously(velocity.X, change.X, 40, Clock.ElapsedFrameTime), + (float)Interpolation.DampContinuously(velocity.Y, change.Y, 40, Clock.ElapsedFrameTime) + ); + } + + lastPosition = Position; + } + } + + public bool FlingLeft() + { + if (!notification.IsInToastTray) + return false; + + if (flinging) + return true; + + if (velocity.X > -0.3f) + velocity.X = -0.3f - 0.5f * RNG.NextSingle(); + + flinging = true; + ClearTransforms(); + return true; + } + + public void ResetPosition() + { + this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); + this.RotateTo(0, 800, Easing.OutElastic); + } + } + + internal class CloseButton : OsuClickableContainer + { + private SpriteIcon icon = null!; + private Box background = null!; + + private readonly IconUsage iconUsage; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public CloseButton(IconUsage iconUsage) + { + this.iconUsage = iconUsage; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Y; + Width = 28; + + Children = new Drawable[] + { + background = new Box + { + Colour = OsuColour.Gray(0).Opacity(0.15f), + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + icon = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = FontAwesome.Solid.TimesCircle, - Size = new Vector2(20), + Icon = iconUsage, + Size = new Vector2(12), + Colour = colourProvider.Foreground1, } }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - hoverColour = colours.Yellow; - } - protected override bool OnHover(HoverEvent e) { - this.FadeColour(hoverColour, 200); + background.FadeIn(200, Easing.OutQuint); + icon.FadeColour(colourProvider.Content1, 200, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - this.FadeColour(OsuColour.Gray(0.2f), 200); + background.FadeOut(200, Easing.OutQuint); + icon.FadeColour(colourProvider.Foreground1, 200, Easing.OutQuint); base.OnHoverLost(e); } } @@ -212,7 +439,7 @@ namespace osu.Game.Overlays.Notifications public class NotificationLight : Container { private bool pulsate; - private Container pulsateLayer; + private Container pulsateLayer = null!; public bool Pulsate { diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index b7e21822fa..16105f913f 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.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; using System.Collections.Generic; using System.Linq; @@ -21,9 +19,9 @@ namespace osu.Game.Overlays.Notifications { public class NotificationSection : AlwaysUpdateFillFlowContainer { - private OsuSpriteText countDrawable; + private OsuSpriteText countDrawable = null!; - private FlowContainer notifications; + private FlowContainer notifications = null!; public int DisplayedCount => notifications.Count(n => !n.WasClosed); public int UnreadCount => notifications.Count(n => !n.WasClosed && !n.Read); @@ -33,14 +31,16 @@ namespace osu.Game.Overlays.Notifications notifications.Insert((int)position, notification); } - public IEnumerable AcceptTypes; + public IEnumerable AcceptedNotificationTypes { get; } private readonly string clearButtonText; private readonly LocalisableString titleText; - public NotificationSection(LocalisableString title, string clearButtonText) + public NotificationSection(LocalisableString title, IEnumerable acceptedNotificationTypes, string clearButtonText) { + AcceptedNotificationTypes = acceptedNotificationTypes.ToArray(); + this.clearButtonText = clearButtonText.ToUpperInvariant(); titleText = title; } @@ -106,14 +106,13 @@ namespace osu.Game.Overlays.Notifications RelativeSizeAxes = Axes.X, LayoutDuration = 150, LayoutEasing = Easing.OutQuart, - Spacing = new Vector2(3), } }); } private void clearAll() { - notifications.Children.ForEach(c => c.Close()); + notifications.Children.ForEach(c => c.Close(true)); } protected override void Update() @@ -159,7 +158,7 @@ namespace osu.Game.Overlays.Notifications public void MarkAllRead() { - notifications?.Children.ForEach(n => n.Read = true); + notifications.Children.ForEach(n => n.Read = true); } } diff --git a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs index cb9d54c14c..3cbdf7edf7 100644 --- a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.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.Allocation; using osu.Game.Graphics; using osu.Framework.Graphics.Colour; @@ -20,7 +18,7 @@ namespace osu.Game.Overlays.Notifications [BackgroundDependencyLoader] private void load(OsuColour colours) { - IconBackground.Colour = ColourInfo.GradientVertical(colours.GreenDark, colours.GreenLight); + IconContent.Colour = ColourInfo.GradientVertical(colours.GreenDark, colours.GreenLight); } } } diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 719e77db83..4cf47013bd 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.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; using System.Threading; using osu.Framework.Allocation; @@ -25,6 +23,20 @@ namespace osu.Game.Overlays.Notifications { private const float loading_spinner_size = 22; + public Func? CancelRequested { get; set; } + + protected override bool AllowFlingDismiss => false; + + /// + /// The function to post completion notifications back to. + /// + public Action? CompletionTarget { get; set; } + + /// + /// An action to complete when the completion notification is clicked. Return true to close. + /// + public Func? CompletionClickAction { get; set; } + private LocalisableString text; public override LocalisableString Text @@ -47,18 +59,21 @@ namespace osu.Game.Overlays.Notifications set { progress = value; - Scheduler.AddOnce(updateProgress, progress); + Scheduler.AddOnce(p => progressBar.Progress = p, progress); } } - private void updateProgress(float progress) => progressBar.Progress = progress; + protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; protected override void LoadComplete() { base.LoadComplete(); // we may have received changes before we were displayed. - updateState(); + Scheduler.AddOnce(updateState); } private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); @@ -74,8 +89,8 @@ namespace osu.Game.Overlays.Notifications state = value; - if (IsLoaded) - Schedule(updateState); + Scheduler.AddOnce(updateState); + attemptPostCompletion(); } } @@ -90,7 +105,7 @@ namespace osu.Game.Overlays.Notifications Light.Pulsate = false; progressBar.Active = false; - iconBackground.FadeColour(ColourInfo.GradientVertical(colourQueued, colourQueued.Lighten(0.5f)), colour_fade_duration); + IconContent.FadeColour(ColourInfo.GradientVertical(colourQueued, colourQueued.Lighten(0.5f)), colour_fade_duration); loadingSpinner.Show(); break; @@ -99,14 +114,14 @@ namespace osu.Game.Overlays.Notifications Light.Pulsate = true; progressBar.Active = true; - iconBackground.FadeColour(ColourInfo.GradientVertical(colourActive, colourActive.Lighten(0.5f)), colour_fade_duration); + IconContent.FadeColour(ColourInfo.GradientVertical(colourActive, colourActive.Lighten(0.5f)), colour_fade_duration); loadingSpinner.Show(); break; case ProgressNotificationState.Cancelled: cancellationTokenSource.Cancel(); - iconBackground.FadeColour(ColourInfo.GradientVertical(Color4.Gray, Color4.Gray.Lighten(0.5f)), colour_fade_duration); + IconContent.FadeColour(ColourInfo.GradientVertical(Color4.Gray, Color4.Gray.Lighten(0.5f)), colour_fade_duration); loadingSpinner.Hide(); var icon = new SpriteIcon @@ -128,12 +143,34 @@ namespace osu.Game.Overlays.Notifications case ProgressNotificationState.Completed: loadingSpinner.Hide(); - NotificationContent.MoveToY(-DrawSize.Y / 2, 200, Easing.OutQuint); - this.FadeOut(200).Finally(_ => Completed()); + attemptPostCompletion(); break; } } + private bool completionSent; + + /// + /// Attempt to post a completion notification. + /// + private void attemptPostCompletion() + { + if (state != ProgressNotificationState.Completed) return; + + // This notification may not have been posted yet (and thus may not have a target to post the completion to). + // Completion posting will be re-attempted in a scheduled invocation. + if (CompletionTarget == null) + return; + + if (completionSent) + return; + + CompletionTarget.Invoke(CreateCompletionNotification()); + completionSent = true; + + Close(false); + } + private ProgressNotificationState state; protected virtual Notification CreateCompletionNotification() => new ProgressCompletionNotification @@ -142,34 +179,28 @@ namespace osu.Game.Overlays.Notifications Text = CompletionText }; - protected virtual void Completed() - { - CompletionTarget?.Invoke(CreateCompletionNotification()); - base.Close(); - } - public override bool DisplayOnTop => false; + public override bool IsImportant => false; + private readonly ProgressBar progressBar; private Color4 colourQueued; private Color4 colourActive; private Color4 colourCancelled; - private Box iconBackground; - private LoadingSpinner loadingSpinner; + private LoadingSpinner loadingSpinner = null!; private readonly TextFlowContainer textDrawable; public ProgressNotification() { - Content.Add(textDrawable = new OsuTextFlowContainer + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { - Colour = OsuColour.Gray(128), AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, }); - NotificationContent.Add(progressBar = new ProgressBar + MainContent.Add(progressBar = new ProgressBar { Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, @@ -194,10 +225,11 @@ namespace osu.Game.Overlays.Notifications IconContent.AddRange(new Drawable[] { - iconBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.White, + Colour = colourProvider.Background5, + Depth = float.MaxValue, }, loadingSpinner = new LoadingSpinner { @@ -206,12 +238,13 @@ namespace osu.Game.Overlays.Notifications }); } - public override void Close() + public override void Close(bool runFlingAnimation) { switch (State) { + case ProgressNotificationState.Completed: case ProgressNotificationState.Cancelled: - base.Close(); + base.Close(runFlingAnimation); break; case ProgressNotificationState.Active: @@ -222,18 +255,6 @@ namespace osu.Game.Overlays.Notifications } } - public Func CancelRequested { get; set; } - - /// - /// The function to post completion notifications back to. - /// - public Action CompletionTarget { get; set; } - - /// - /// An action to complete when the completion notification is clicked. Return true to close. - /// - public Func CompletionClickAction; - private class ProgressBar : Container { private readonly Box box; diff --git a/osu.Game/Overlays/Notifications/SimpleNotification.cs b/osu.Game/Overlays/Notifications/SimpleNotification.cs index fc594902cf..f3bb6a0578 100644 --- a/osu.Game/Overlays/Notifications/SimpleNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleNotification.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.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -26,7 +24,8 @@ namespace osu.Game.Overlays.Notifications set { text = value; - textDrawable.Text = text; + if (textDrawable != null) + textDrawable.Text = text; } } @@ -38,48 +37,50 @@ namespace osu.Game.Overlays.Notifications set { icon = value; - iconDrawable.Icon = icon; + if (iconDrawable != null) + iconDrawable.Icon = icon; } } - private readonly TextFlowContainer textDrawable; - private readonly SpriteIcon iconDrawable; - - protected Box IconBackground; - - public SimpleNotification() + public ColourInfo IconColour { + get => IconContent.Colour; + set => IconContent.Colour = value; + } + + private TextFlowContainer? textDrawable; + + private SpriteIcon? iconDrawable; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + Light.Colour = colours.Green; + IconContent.AddRange(new Drawable[] { - IconBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.Gray(0.2f), OsuColour.Gray(0.6f)) + Colour = colourProvider.Background5, }, iconDrawable = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, Icon = icon, - Size = new Vector2(20), + Size = new Vector2(16), } }); - Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14)) + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { - Colour = OsuColour.Gray(128), AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Text = text }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Light.Colour = colours.Green; - } - public override bool Read { get => base.Read; diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 6eddc7da83..900b4bebf0 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -38,6 +38,7 @@ namespace osu.Game.Overlays private const float transition_length = 800; private const float progress_height = 10; private const float bottom_black_area_height = 55; + private const float margin = 10; private Drawable background; private ProgressBar progressBar; @@ -53,6 +54,7 @@ namespace osu.Game.Overlays private Container dragContainer; private Container playerContainer; + private Container playlistContainer; protected override string PopInSampleName => "UI/now-playing-pop-in"; protected override string PopOutSampleName => "UI/now-playing-pop-out"; @@ -69,7 +71,7 @@ namespace osu.Game.Overlays public NowPlayingOverlay() { Width = 400; - Margin = new MarginPadding(10); + Margin = new MarginPadding(margin); } [BackgroundDependencyLoader] @@ -82,7 +84,6 @@ namespace osu.Game.Overlays Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, Children = new Drawable[] { playerContainer = new Container @@ -182,8 +183,13 @@ namespace osu.Game.Overlays } }, }, + playlistContainer = new Container + { + RelativeSizeAxes = Axes.X, + Y = player_height + margin, + } } - } + }, }; } @@ -193,11 +199,10 @@ namespace osu.Game.Overlays { LoadComponentAsync(playlist = new PlaylistOverlay { - RelativeSizeAxes = Axes.X, - Y = player_height + 10, + RelativeSizeAxes = Axes.Both, }, _ => { - dragContainer.Add(playlist); + playlistContainer.Add(playlist); playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); @@ -242,7 +247,18 @@ namespace osu.Game.Overlays { base.UpdateAfterChildren(); - Height = dragContainer.Height; + playlistContainer.Height = MathF.Min(Parent.DrawHeight - margin * 3 - player_height, PlaylistOverlay.PLAYLIST_HEIGHT); + + float height = player_height; + + if (playlist != null) + { + height += playlist.DrawHeight; + if (playlist.State.Value == Visibility.Visible) + height += margin; + } + + Height = dragContainer.Height = height; } protected override void Update() diff --git a/osu.Game/Overlays/OSD/CopyUrlToast.cs b/osu.Game/Overlays/OSD/CopyUrlToast.cs new file mode 100644 index 0000000000..ea835a1c5e --- /dev/null +++ b/osu.Game/Overlays/OSD/CopyUrlToast.cs @@ -0,0 +1,15 @@ +// 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.Localisation; + +namespace osu.Game.Overlays.OSD +{ + public class CopyUrlToast : Toast + { + public CopyUrlToast() + : base(UserInterfaceStrings.GeneralHeader, ToastStrings.UrlCopied, "") + { + } + } +} diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index f02d2b388f..befb011353 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -26,6 +26,8 @@ namespace osu.Game.Overlays { public class OverlaySortTabControl : CompositeDrawable, IHasCurrentValue { + public TabControl TabControl { get; } + private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current @@ -59,7 +61,7 @@ namespace osu.Game.Overlays Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = SortStrings.Default }, - CreateControl().With(c => + TabControl = CreateControl().With(c => { c.Anchor = Anchor.CentreLeft; c.Origin = Anchor.CentreLeft; diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 9c957e387a..67a6df3228 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -13,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; @@ -69,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), - ShowPlaceholderOnNull = 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, + } + } + }, + } } } } @@ -174,8 +181,8 @@ namespace osu.Game.Overlays.Profile.Header avatar.User = user; usernameText.Text = user?.Username ?? string.Empty; openUserExternally.Link = $@"{api.WebsiteRootUrl}/users/{user?.Id ?? 0}"; - userFlag.Country = user?.Country; - userCountryText.Text = user?.Country?.FullName ?? "Alien"; + userFlag.CountryCode = user?.CountryCode ?? default; + userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index f84f829f7a..1eca6a81cf 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Diagnostics; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -37,6 +38,10 @@ namespace osu.Game.Overlays.Profile // todo: pending implementation. // TabControl.AddItem(LayoutStrings.HeaderUsersModding); + // Haphazardly guaranteed by OverlayHeader constructor (see CreateBackground / CreateContent). + Debug.Assert(centreHeaderContainer != null); + Debug.Assert(detailHeaderContainer != null); + centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true); } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 90a357a281..fda2db7acc 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -19,6 +19,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; using osu.Game.Rulesets.UI; +using osu.Game.Scoring.Drawables; using osu.Game.Utils; using osuTK; @@ -31,7 +32,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private const float performance_background_shear = 0.45f; - protected readonly APIScore Score; + protected readonly SoloScoreInfo Score; [Resolved] private OsuColour colours { get; set; } @@ -39,7 +40,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks [Resolved] private OverlayColourProvider colourProvider { get; set; } - public DrawableProfileScore(APIScore score) + public DrawableProfileScore(SoloScoreInfo score) { Score = score; @@ -98,7 +99,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), Colour = colours.Yellow }, - new DrawableDate(Score.Date, 12) + new DrawableDate(Score.EndedAt, 12) { Colour = colourProvider.Foreground1 } @@ -138,7 +139,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { var ruleset = rulesets.GetRuleset(Score.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {Score.RulesetID} not found locally"); - return new ModIcon(ruleset.CreateInstance().CreateModFromAcronym(mod.Acronym)) + return new ModIcon(mod.ToMod(ruleset.CreateInstance())) { Scale = new Vector2(0.35f) }; @@ -218,39 +219,42 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private Drawable createDrawablePerformance() { - if (Score.PP.HasValue) + if (!Score.PP.HasValue) { - return new FillFlowContainer + if (Score.Beatmap?.Status.GrantsPerformancePoints() == true) + return new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(16), Colour = colourProvider.Highlight1 }; + + return new OsuSpriteText { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = $"{Score.PP:0}", - Colour = colourProvider.Highlight1 - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = "pp", - Colour = colourProvider.Light3 - } - } + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = "-", + Colour = colourProvider.Highlight1 }; } - return new OsuSpriteText + return new FillFlowContainer { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = "-", - Colour = colourProvider.Highlight1 + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = $"{Score.PP:0}", + Colour = colourProvider.Highlight1 + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Text = "pp", + Colour = colourProvider.Light3 + } + } }; } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index f8f83883fc..8c46f10ba2 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly double weight; - public DrawableProfileWeightedScore(APIScore score, double weight) + public DrawableProfileWeightedScore(SoloScoreInfo score, double weight) : base(score) { this.weight = weight; @@ -42,12 +42,11 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks CreateDrawableAccuracy(), new Container { - AutoSizeAxes = Axes.Y, - Width = 50, + Size = new Vector2(50, 14), Child = new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = $"{Score.PP * weight:0}pp", + Text = Score.PP.HasValue ? $"{Score.PP * weight:0}pp" : string.Empty, }, } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 15c7b8f042..2564692c87 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -17,7 +17,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class PaginatedScoreContainer : PaginatedProfileSubsection + public class PaginatedScoreContainer : PaginatedProfileSubsection { private readonly ScoreType type; @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks } } - protected override void OnItemsReceived(List items) + protected override void OnItemsReceived(List items) { if (CurrentPage == null || CurrentPage?.Offset == 0) drawableItemIndex = 0; @@ -62,12 +62,12 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks base.OnItemsReceived(items); } - protected override APIRequest> CreateRequest(PaginationParameters pagination) => + protected override APIRequest> CreateRequest(PaginationParameters pagination) => new GetUserScoresRequest(User.Value.Id, type, pagination); private int drawableItemIndex; - protected override Drawable CreateDrawableItem(APIScore model) + protected override Drawable CreateDrawableItem(SoloScoreInfo model) { switch (type) { diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index 943c105008..6b71ae73e3 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -120,7 +120,13 @@ namespace osu.Game.Overlays.Profile.Sections.Recent }; default: - return Empty(); + return new RecentActivityIcon(activity) + { + RelativeSizeAxes = Axes.X, + Height = 11, + FillMode = FillMode.Fit, + Margin = new MarginPadding { Top = 2, Vertical = 2 } + }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/RecentActivityIcon.cs b/osu.Game/Overlays/Profile/Sections/Recent/RecentActivityIcon.cs new file mode 100644 index 0000000000..c55eedd1ed --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Recent/RecentActivityIcon.cs @@ -0,0 +1,119 @@ +// 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.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests; +using osu.Framework.Allocation; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Profile.Sections.Recent +{ + public class RecentActivityIcon : Container + { + private readonly SpriteIcon icon; + private readonly APIRecentActivity activity; + + public RecentActivityIcon(APIRecentActivity activity) + { + this.activity = activity; + Child = icon = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + // references: + // https://github.com/ppy/osu-web/blob/659b371dcadf25b4f601a4c9895a813078301084/resources/assets/lib/profile-page/parse-event.tsx + // https://github.com/ppy/osu-web/blob/master/resources/assets/less/bem/profile-extra-entries.less#L98-L128 + switch (activity.Type) + { + case RecentActivityType.BeatmapPlaycount: + icon.Icon = FontAwesome.Solid.Play; + icon.Colour = Color4.White; + break; + + case RecentActivityType.BeatmapsetApprove: + icon.Icon = FontAwesome.Solid.Check; + icon.Colour = getColorForApprovalType(activity.Approval); + break; + + case RecentActivityType.BeatmapsetDelete: + icon.Icon = FontAwesome.Solid.TrashAlt; + icon.Colour = colours.Red1; + break; + + case RecentActivityType.BeatmapsetRevive: + icon.Icon = FontAwesome.Solid.TrashRestore; + icon.Colour = Color4.White; + break; + + case RecentActivityType.BeatmapsetUpdate: + icon.Icon = FontAwesome.Solid.SyncAlt; + icon.Colour = colours.Green1; + break; + + case RecentActivityType.BeatmapsetUpload: + icon.Icon = FontAwesome.Solid.ArrowUp; + icon.Colour = colours.Orange1; + break; + + case RecentActivityType.RankLost: + icon.Icon = FontAwesome.Solid.AngleDoubleDown; + icon.Colour = Color4.White; + break; + + case RecentActivityType.UserSupportAgain: + icon.Icon = FontAwesome.Solid.Heart; + icon.Colour = colours.Pink; + break; + + case RecentActivityType.UserSupportFirst: + icon.Icon = FontAwesome.Solid.Heart; + icon.Colour = colours.Pink; + break; + + case RecentActivityType.UserSupportGift: + icon.Icon = FontAwesome.Solid.Gift; + icon.Colour = colours.Pink; + break; + + case RecentActivityType.UsernameChange: + icon.Icon = FontAwesome.Solid.Tag; + icon.Colour = Color4.White; + break; + } + } + + private Color4 getColorForApprovalType(BeatmapApproval approvalType) + { + switch (approvalType) + { + case BeatmapApproval.Approved: + case BeatmapApproval.Ranked: + return colours.Lime1; + + case BeatmapApproval.Loved: + return colours.Pink1; + + case BeatmapApproval.Qualified: + return colours.Blue1; + + default: + throw new ArgumentOutOfRangeException($"Unsupported {nameof(BeatmapApproval)} type", approvalType, nameof(approvalType)); + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/CountryFilter.cs b/osu.Game/Overlays/Rankings/CountryFilter.cs index 9ba2018522..9376bafdff 100644 --- a/osu.Game/Overlays/Rankings/CountryFilter.cs +++ b/osu.Game/Overlays/Rankings/CountryFilter.cs @@ -16,14 +16,14 @@ using osuTK; namespace osu.Game.Overlays.Rankings { - public class CountryFilter : CompositeDrawable, IHasCurrentValue + public class CountryFilter : CompositeDrawable, IHasCurrentValue { private const int duration = 200; private const int height = 70; - private readonly BindableWithCurrent current = new BindableWithCurrent(); + private readonly BindableWithCurrent current = new BindableWithCurrent(); - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; @@ -89,9 +89,9 @@ namespace osu.Game.Overlays.Rankings Current.BindValueChanged(onCountryChanged, true); } - private void onCountryChanged(ValueChangedEvent country) + private void onCountryChanged(ValueChangedEvent country) { - if (country.NewValue == null) + if (Current.Value == CountryCode.Unknown) { countryPill.Collapse(); this.ResizeHeightTo(0, duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Rankings/CountryPill.cs b/osu.Game/Overlays/Rankings/CountryPill.cs index 90f8c85557..ee4c4f08af 100644 --- a/osu.Game/Overlays/Rankings/CountryPill.cs +++ b/osu.Game/Overlays/Rankings/CountryPill.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -21,13 +22,13 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Rankings { - public class CountryPill : CompositeDrawable, IHasCurrentValue + public class CountryPill : CompositeDrawable, IHasCurrentValue { private const int duration = 200; - private readonly BindableWithCurrent current = new BindableWithCurrent(); + private readonly BindableWithCurrent current = new BindableWithCurrent(); - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; @@ -93,7 +94,7 @@ namespace osu.Game.Overlays.Rankings { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Action = () => Current.Value = null + Action = Current.SetDefault, } } } @@ -130,13 +131,13 @@ namespace osu.Game.Overlays.Rankings this.FadeOut(duration, Easing.OutQuint); } - private void onCountryChanged(ValueChangedEvent country) + private void onCountryChanged(ValueChangedEvent country) { - if (country.NewValue == null) + if (Current.Value == CountryCode.Unknown) return; - flag.Country = country.NewValue; - countryName.Text = country.NewValue.FullName; + flag.CountryCode = country.NewValue; + countryName.Text = country.NewValue.GetDescription(); } private class CloseButton : OsuHoverContainer diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs index 9b0b75f663..936545bd49 100644 --- a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Rankings { public Bindable Ruleset => rulesetSelector.Current; - public Bindable Country => countryFilter.Current; + public Bindable Country => countryFilter.Current; private OverlayRulesetSelector rulesetSelector; private CountryFilter countryFilter; diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index 53d10b3e53..a6f0c7a123 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Game.Resources.Localisation.Web; @@ -33,9 +34,9 @@ namespace osu.Game.Overlays.Rankings.Tables new RankingsTableColumn(RankingsStrings.StatAveragePerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), }; - protected override Country GetCountry(CountryStatistics item) => item.Country; + protected override CountryCode GetCountryCode(CountryStatistics item) => item.Code; - protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Country); + protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Code); protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { @@ -70,15 +71,15 @@ namespace osu.Game.Overlays.Rankings.Tables [Resolved(canBeNull: true)] private RankingsOverlay rankings { get; set; } - public CountryName(Country country) + public CountryName(CountryCode countryCode) : base(t => t.Font = OsuFont.GetFont(size: 12)) { AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; TextAnchor = Anchor.CentreLeft; - if (!string.IsNullOrEmpty(country.FullName)) - AddLink(country.FullName, () => rankings?.ShowCountry(country)); + if (countryCode != CountryCode.Unknown) + AddLink(countryCode.GetDescription(), () => rankings?.ShowCountry(countryCode)); } } } diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index 56e318637a..073bf86a7a 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -79,7 +79,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override Drawable CreateHeader(int index, TableColumn column) => (column as RankingsTableColumn)?.CreateHeaderText() ?? new HeaderText(column?.Header ?? default, false); - protected abstract Country GetCountry(TModel item); + protected abstract CountryCode GetCountryCode(TModel item); protected abstract Drawable CreateFlagContent(TModel item); @@ -97,10 +97,10 @@ namespace osu.Game.Overlays.Rankings.Tables Margin = new MarginPadding { Bottom = row_spacing }, Children = new[] { - new UpdateableFlag(GetCountry(item)) + new UpdateableFlag(GetCountryCode(item)) { Size = new Vector2(28, 20), - ShowPlaceholderOnNull = false, + ShowPlaceholderOnUnknown = false, }, CreateFlagContent(item) } diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index 2edd1b21ba..48185e6083 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.Rankings.Tables .Concat(GradeColumns.Select(grade => new GradeTableColumn(grade, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)))) .ToArray(); - protected sealed override Country GetCountry(UserStatistics item) => item.User.Country; + protected sealed override CountryCode GetCountryCode(UserStatistics item) => item.User.CountryCode; protected sealed override Drawable CreateFlagContent(UserStatistics item) { diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index a66b8cf2ba..586b883604 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -17,7 +17,7 @@ namespace osu.Game.Overlays { public class RankingsOverlay : TabbableOnlineOverlay { - protected Bindable Country => Header.Country; + protected Bindable Country => Header.Country; private APIRequest lastRequest; @@ -44,7 +44,7 @@ namespace osu.Game.Overlays Country.BindValueChanged(_ => { // if a country is requested, force performance scope. - if (Country.Value != null) + if (!Country.IsDefault) Header.Current.Value = RankingsScope.Performance; Scheduler.AddOnce(triggerTabChanged); @@ -76,7 +76,7 @@ namespace osu.Game.Overlays { // country filtering is only valid for performance scope. if (Header.Current.Value != RankingsScope.Performance) - Country.Value = null; + Country.SetDefault(); Scheduler.AddOnce(triggerTabChanged); } @@ -85,9 +85,9 @@ namespace osu.Game.Overlays protected override RankingsOverlayHeader CreateHeader() => new RankingsOverlayHeader(); - public void ShowCountry(Country requested) + public void ShowCountry(CountryCode requested) { - if (requested == null) + if (requested == default) return; Show(); @@ -128,7 +128,7 @@ namespace osu.Game.Overlays switch (Header.Current.Value) { case RankingsScope.Performance: - return new GetUserRankingsRequest(ruleset.Value, country: Country.Value?.FlagName); + return new GetUserRankingsRequest(ruleset.Value, countryCode: Country.Value); case RankingsScope.Country: return new GetCountryRankingsRequest(ruleset.Value); diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index cea8fdd733..8f188f04d9 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -21,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { Children = new Drawable[] { - new SettingsSlider + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.MasterVolume, Current = audio.Volume, @@ -35,14 +36,15 @@ namespace osu.Game.Overlays.Settings.Sections.Audio KeyboardStep = 0.01f, DisplayAsPercentage = true }, - new SettingsSlider + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.EffectVolume, Current = audio.VolumeSample, KeyboardStep = 0.01f, DisplayAsPercentage = true }, - new SettingsSlider + + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.MusicVolume, Current = audio.VolumeTrack, @@ -51,5 +53,15 @@ namespace osu.Game.Overlays.Settings.Sections.Audio }, }; } + + private class VolumeAdjustSlider : SettingsSlider + { + protected override Drawable CreateControl() + { + var sliderBar = (OsuSliderBar)base.CreateControl(); + sliderBar.PlaySamplesOnAdjust = false; + return sliderBar; + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index 42ac4adb34..3afb060e49 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -35,7 +35,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings }, new SettingsButton { - Text = DebugSettingsStrings.CompactRealm, + Text = "Compact realm", Action = () => { // Blocking operations implicitly causes a Compact(). diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs index 0893af7d3e..88a27840d8 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -38,6 +38,11 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Current = config.GetBindable(OsuSetting.KeyOverlay), Keywords = new[] { "counter" }, }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysShowGameplayLeaderboard, + Current = config.GetBindable(OsuSetting.GameplayLeaderboard), + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs index b5315d5268..ac59a6c0ed 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -32,6 +32,11 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = SkinSettingsStrings.AutoCursorSize, Current = config.GetBindable(OsuSetting.AutoCursorSize) }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.GameplayCursorDuringTouch, + Current = config.GetBindable(OsuSetting.GameplayCursorDuringTouch) + }, }; if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 63f0dec953..0f77e6609b 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -44,9 +44,12 @@ namespace osu.Game.Overlays.Settings.Sections.General }, }; - if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale)) - locale = Language.en; - languageSelection.Current.Value = locale; + frameworkLocale.BindValueChanged(locale => + { + if (!LanguageExtensions.TryParseCultureCode(locale.NewValue, out var language)) + language = Language.en; + languageSelection.Current.Value = language; + }, true); languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index b68a4fed48..d97cf699e5 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { notifications?.Post(new SimpleNotification { - Text = $"You are running the latest release ({game.Version})", + Text = GeneralSettingsStrings.RunningLatestRelease(game.Version), Icon = FontAwesome.Solid.CheckCircle, }); } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 28642f12a1..59b56522a4 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -1,9 +1,8 @@ // 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.Drawing; using System.Linq; using osu.Framework; @@ -29,37 +28,41 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { protected override LocalisableString Header => GraphicsSettingsStrings.LayoutHeader; - private FillFlowContainer> scalingSettings; + private FillFlowContainer> scalingSettings = null!; private readonly Bindable currentDisplay = new Bindable(); private readonly IBindableList windowModes = new BindableList(); - private Bindable scalingMode; - private Bindable sizeFullscreen; + private Bindable scalingMode = null!; + private Bindable sizeFullscreen = null!; private readonly BindableList resolutions = new BindableList(new[] { new Size(9999, 9999) }); private readonly IBindable fullscreenCapability = new Bindable(FullscreenCapability.Capable); [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; - private SettingsDropdown resolutionDropdown; - private SettingsDropdown displayDropdown; - private SettingsDropdown windowModeDropdown; + private IWindow? window; - private Bindable scalingPositionX; - private Bindable scalingPositionY; - private Bindable scalingSizeX; - private Bindable scalingSizeY; + private SettingsDropdown resolutionDropdown = null!; + private SettingsDropdown displayDropdown = null!; + private SettingsDropdown windowModeDropdown = null!; + + private Bindable scalingPositionX = null!; + private Bindable scalingPositionY = null!; + private Bindable scalingSizeX = null!; + private Bindable scalingSizeY = null!; private const int transition_duration = 400; [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host) { + window = host.Window; + scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); sizeFullscreen = config.GetBindable(FrameworkSetting.SizeFullscreen); scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); @@ -67,14 +70,15 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingPositionX = osuConfig.GetBindable(OsuSetting.ScalingPositionX); scalingPositionY = osuConfig.GetBindable(OsuSetting.ScalingPositionY); - if (host.Window != null) + if (window != null) { - currentDisplay.BindTo(host.Window.CurrentDisplayBindable); - windowModes.BindTo(host.Window.SupportedWindowModes); + currentDisplay.BindTo(window.CurrentDisplayBindable); + windowModes.BindTo(window.SupportedWindowModes); + window.DisplaysChanged += onDisplaysChanged; } - if (host.Window is WindowsWindow windowsWindow) - fullscreenCapability.BindTo(windowsWindow.FullscreenCapability); + if (host.Renderer is IWindowsRenderer windowsRenderer) + fullscreenCapability.BindTo(windowsRenderer.FullscreenCapability); Children = new Drawable[] { @@ -87,7 +91,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics displayDropdown = new DisplaySettingsDropdown { LabelText = GraphicsSettingsStrings.Display, - Items = host.Window?.Displays, + Items = window?.Displays, Current = currentDisplay, }, resolutionDropdown = new ResolutionSettingsDropdown @@ -202,19 +206,6 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics // initial update bypasses transforms updateScalingModeVisibility(); - void updateDisplayModeDropdowns() - { - if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen) - resolutionDropdown.Show(); - else - resolutionDropdown.Hide(); - - if (displayDropdown.Items.Count() > 1) - displayDropdown.Show(); - else - displayDropdown.Hide(); - } - void updateScalingModeVisibility() { if (scalingMode.Value == ScalingMode.Off) @@ -225,6 +216,28 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } } + private void onDisplaysChanged(IEnumerable displays) + { + Scheduler.AddOnce(d => + { + displayDropdown.Items = d; + updateDisplayModeDropdowns(); + }, displays); + } + + private void updateDisplayModeDropdowns() + { + if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen) + resolutionDropdown.Show(); + else + resolutionDropdown.Hide(); + + if (displayDropdown.Items.Count() > 1) + displayDropdown.Show(); + else + displayDropdown.Hide(); + } + private void updateScreenModeWarning() { if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS) @@ -280,7 +293,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics }; } - private Drawable preview; + private Drawable? preview; private void showPreview() { @@ -291,6 +304,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics preview.Expire(); } + protected override void Dispose(bool isDisposing) + { + if (window != null) + window.DisplaysChanged -= onDisplaysChanged; + + base.Dispose(isDisposing); + } + private class ScalingPreview : ScalingContainer { public ScalingPreview() diff --git a/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs index a2e07065f1..7ab10a3c3a 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.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.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -23,6 +21,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input public GlobalKeyBindingsSection(GlobalActionContainer manager) { Add(new DefaultBindingsSubsection(manager)); + Add(new OverlayBindingsSubsection(manager)); Add(new AudioControlKeyBindingsSubsection(manager)); Add(new SongSelectKeyBindingSubsection(manager)); Add(new InGameKeyBindingsSubsection(manager)); @@ -40,6 +39,17 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } + private class OverlayBindingsSubsection : KeyBindingsSubsection + { + protected override LocalisableString Header => InputSettingsStrings.OverlaysSection; + + public OverlayBindingsSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.OverlayKeyBindings; + } + } + private class SongSelectKeyBindingSubsection : KeyBindingsSubsection { protected override LocalisableString Header => InputSettingsStrings.SongSelectSection; diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index 9ff47578e9..c91a6a48d4 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -327,6 +327,50 @@ namespace osu.Game.Overlays.Settings.Sections.Input finalise(); } + protected override bool OnTabletAuxiliaryButtonPress(TabletAuxiliaryButtonPressEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + finalise(); + + return true; + } + + protected override void OnTabletAuxiliaryButtonRelease(TabletAuxiliaryButtonReleaseEvent e) + { + if (!HasFocus) + { + base.OnTabletAuxiliaryButtonRelease(e); + return; + } + + finalise(); + } + + protected override bool OnTabletPenButtonPress(TabletPenButtonPressEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + finalise(); + + return true; + } + + protected override void OnTabletPenButtonRelease(TabletPenButtonReleaseEvent e) + { + if (!HasFocus) + { + base.OnTabletPenButtonRelease(e); + return; + } + + finalise(); + } + private void clear() { if (bindTarget == null) @@ -387,14 +431,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (bindTarget != null) bindTarget.IsBinding = true; } - private void updateStoreFromButton(KeyButton button) - { - realm.Run(r => - { - var binding = r.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); - r.Write(() => binding.KeyCombinationString = button.KeyBinding.KeyCombinationString); - }); - } + private void updateStoreFromButton(KeyButton button) => + realm.WriteAsync(r => r.Find(button.KeyBinding.ID).KeyCombinationString = button.KeyBinding.KeyCombinationString); private void updateIsDefaultValue() { diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 271438ed14..f1e216f538 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -72,7 +72,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, LocalisationManager localisation) { Children = new Drawable[] { @@ -110,11 +110,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) { t.NewLine(); - t.AddText("If your tablet is not detected, please read "); - t.AddLink("this FAQ", LinkAction.External, RuntimeInfo.OS == RuntimeInfo.Platform.Windows + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription(RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux"); - t.AddText(" for troubleshooting steps."); + : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); + t.AddLinks(formattedSource.Text, formattedSource.Links); } }), } @@ -215,21 +214,21 @@ namespace osu.Game.Overlays.Settings.Sections.Input rotation.BindTo(tabletHandler.Rotation); areaOffset.BindTo(tabletHandler.AreaOffset); - areaOffset.BindValueChanged(val => + areaOffset.BindValueChanged(val => Schedule(() => { offsetX.Value = val.NewValue.X; offsetY.Value = val.NewValue.Y; - }, true); + }), true); offsetX.BindValueChanged(val => areaOffset.Value = new Vector2(val.NewValue, areaOffset.Value.Y)); offsetY.BindValueChanged(val => areaOffset.Value = new Vector2(areaOffset.Value.X, val.NewValue)); areaSize.BindTo(tabletHandler.AreaSize); - areaSize.BindValueChanged(val => + areaSize.BindValueChanged(val => Schedule(() => { sizeX.Value = val.NewValue.X; sizeY.Value = val.NewValue.Y; - }, true); + }), true); sizeX.BindValueChanged(val => { @@ -255,7 +254,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }); tablet.BindTo(tabletHandler.Tablet); - tablet.BindValueChanged(val => + tablet.BindValueChanged(val => Schedule(() => { Scheduler.AddOnce(updateVisibility); @@ -274,7 +273,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input sizeY.Default = sizeY.MaxValue = tab.Size.Y; areaSize.Default = new Vector2(sizeX.Default, sizeY.Default); - }, true); + }), true); } private void updateVisibility() 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/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 4d75537f6b..a8fe3d04be 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -3,6 +3,8 @@ #nullable disable +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -20,6 +22,8 @@ namespace osu.Game.Overlays.Settings.Sections public override LocalisableString Header => InputSettingsStrings.InputSectionHeader; + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "keybindings" }); + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.Keyboard diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs index 453dbd2e18..beae5a6aad 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class BeatmapSettings : SettingsSubsection { - protected override LocalisableString Header => "Beatmaps"; + protected override LocalisableString Header => CommonStrings.Beatmaps; private SettingsButton importBeatmapsButton = null!; private SettingsButton deleteBeatmapsButton = null!; diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs index 5367f644ca..17fef37e40 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs @@ -6,20 +6,25 @@ using osu.Framework.Localisation; using osu.Game.Collections; using osu.Game.Database; using osu.Game.Localisation; +using osu.Game.Overlays.Notifications; namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class CollectionsSettings : SettingsSubsection { - protected override LocalisableString Header => "Collections"; + protected override LocalisableString Header => CommonStrings.Collections; private SettingsButton importCollectionsButton = null!; - [BackgroundDependencyLoader] - private void load(CollectionManager? collectionManager, LegacyImportManager? legacyImportManager, IDialogOverlay? dialogOverlay) - { - if (collectionManager == null) return; + [Resolved] + private RealmAccess realm { get; set; } = null!; + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + [BackgroundDependencyLoader] + private void load(LegacyImportManager? legacyImportManager, IDialogOverlay? dialogOverlay) + { if (legacyImportManager?.SupportsImportFromStable == true) { Add(importCollectionsButton = new SettingsButton @@ -38,9 +43,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Text = MaintenanceSettingsStrings.DeleteAllCollections, Action = () => { - dialogOverlay?.Push(new MassDeleteConfirmationDialog(collectionManager.DeleteAll)); + dialogOverlay?.Push(new MassDeleteConfirmationDialog(deleteAllCollections)); } }); } + + private void deleteAllCollections() + { + realm.Write(r => r.RemoveAll()); + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.DeletedAllCollections }); + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs index 526c096493..19e6f83dac 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs @@ -1,34 +1,17 @@ // 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.Settings.Sections.Maintenance { - public class MassDeleteConfirmationDialog : PopupDialog + public class MassDeleteConfirmationDialog : DeleteConfirmationDialog { public MassDeleteConfirmationDialog(Action deleteAction) { BodyText = "Everything?"; - - Icon = FontAwesome.Regular.TrashAlt; - HeaderText = @"Confirm deletion of"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = @"Yes. Go for it.", - Action = deleteAction - }, - new PopupDialogCancelButton - { - Text = @"No! Abort mission!", - }, - }; + DeleteAction = deleteAction; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs index e3870bc76a..fc8c9d497b 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.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; namespace osu.Game.Overlays.Settings.Sections.Maintenance diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index d565576d09..158e1a8aa0 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -15,6 +15,7 @@ using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osuTK; @@ -71,14 +72,14 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Migration in progress", + Text = MaintenanceSettingsStrings.MigrationInProgress, Font = OsuFont.Default.With(size: 40) }, new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "This could take a few minutes depending on the speed of your disk(s).", + Text = MaintenanceSettingsStrings.MigrationDescription, Font = OsuFont.Default.With(size: 30) }, new LoadingSpinner(true) @@ -89,7 +90,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Please avoid interacting with the game!", + Text = MaintenanceSettingsStrings.ProhibitedInteractDuringMigration, Font = OsuFont.Default.With(size: 30) }, } @@ -111,7 +112,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { notifications.Post(new SimpleNotification { - Text = "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up.", + Text = MaintenanceSettingsStrings.FailedCleanupNotification, Activated = () => { originalStorage.PresentExternally(); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs index 0d32e33d87..5de33fdd55 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.IO; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Overlays.Settings.Sections.Maintenance @@ -35,7 +36,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance public override bool HideOverlaysOnEnter => true; - public override LocalisableString HeaderText => "Please select a new location"; + public override LocalisableString HeaderText => MaintenanceSettingsStrings.SelectNewLocation; protected override void OnSelection(DirectoryInfo directory) { @@ -51,9 +52,9 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance // Quick test for whether there's already an osu! install at the target path. if (fileInfos.Any(f => f.Name == OsuGameBase.CLIENT_DATABASE_FILENAME)) { - dialogOverlay.Push(new ConfirmDialog("The target directory already seems to have an osu! install. Use that data instead?", () => + dialogOverlay.Push(new ConfirmDialog(MaintenanceSettingsStrings.TargetDirectoryAlreadyInstalledOsu, () => { - dialogOverlay.Push(new ConfirmDialog("To complete this operation, osu! will close. Please open it again to use the new data location.", () => + dialogOverlay.Push(new ConfirmDialog(MaintenanceSettingsStrings.RestartAndReOpenRequiredForCompletion, () => { (storage as OsuStorage)?.ChangeDataPath(target.FullName); game.Exit(); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs new file mode 100644 index 0000000000..51f6e1bf60 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Database; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets.Mods; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class ModPresetSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.ModPresets; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + private SettingsButton undeleteButton = null!; + private SettingsButton deleteAllButton = null!; + + [BackgroundDependencyLoader] + private void load(IDialogOverlay? dialogOverlay) + { + AddRange(new Drawable[] + { + deleteAllButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllModPresets, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + deleteAllButton.Enabled.Value = false; + Task.Run(deleteAllModPresets).ContinueWith(t => Schedule(onAllModPresetsDeleted, t)); + })); + } + }, + undeleteButton = new SettingsButton + { + Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedModPresets, + Action = () => Task.Run(undeleteModPresets).ContinueWith(t => Schedule(onModPresetsUndeleted, t)) + } + }); + } + + private void deleteAllModPresets() => + realm.Write(r => + { + foreach (var preset in r.All()) + preset.DeletePending = true; + }); + + private void onAllModPresetsDeleted(Task deletionTask) + { + deleteAllButton.Enabled.Value = true; + + if (deletionTask.IsCompletedSuccessfully) + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.DeletedAllModPresets }); + else if (deletionTask.IsFaulted) + Logger.Error(deletionTask.Exception, "Failed to delete all mod presets"); + } + + private void undeleteModPresets() => + realm.Write(r => + { + foreach (var preset in r.All().Where(preset => preset.DeletePending)) + preset.DeletePending = false; + }); + + private void onModPresetsUndeleted(Task undeletionTask) + { + undeleteButton.Enabled.Value = true; + + if (undeletionTask.IsCompletedSuccessfully) + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.RestoredAllDeletedModPresets }); + else if (undeletionTask.IsFaulted) + Logger.Error(undeletionTask.Exception, "Failed to restore mod presets"); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs index 70e11d45dc..eb2d3171ea 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class ScoreSettings : SettingsSubsection { - protected override LocalisableString Header => "Scores"; + protected override LocalisableString Header => CommonStrings.Scores; private SettingsButton importScoresButton = null!; private SettingsButton deleteScoresButton = null!; diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs index c95b1d4dd8..93c65513b7 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class SkinSettings : SettingsSubsection { - protected override LocalisableString Header => "Skins"; + protected override LocalisableString Header => CommonStrings.Skins; private SettingsButton importSkinsButton = null!; private SettingsButton deleteSkinsButton = null!; diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index 841a7d0abe..4be6d1653b 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -25,7 +25,8 @@ namespace osu.Game.Overlays.Settings.Sections new BeatmapSettings(), new SkinSettings(), new CollectionsSettings(), - new ScoreSettings() + new ScoreSettings(), + new ModPresetSettings() }; } } diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs index 2368459b4e..6f0b3c27a0 100644 --- a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -1,17 +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.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; -using osu.Game.Rulesets; using osu.Game.Localisation; +using osu.Game.Rulesets; namespace osu.Game.Overlays.Settings.Sections { @@ -31,14 +28,14 @@ namespace osu.Game.Overlays.Settings.Sections { try { - SettingsSubsection section = ruleset.CreateSettings(); + SettingsSubsection? section = ruleset.CreateSettings(); if (section != null) Add(section); } - catch (Exception e) + catch { - Logger.Error(e, "Failed to load ruleset settings"); + Logger.Log($"Failed to load ruleset settings for {ruleset.RulesetInfo.Name}. Please check for an update from the developer.", level: LogLevel.Error); } } } diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index d23ef7e3e7..f602b73065 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; @@ -36,9 +35,6 @@ namespace osu.Game.Overlays.Settings.Sections Icon = FontAwesome.Solid.PaintBrush }; - private readonly Bindable> dropdownBindable = new Bindable> { Default = DefaultSkin.CreateInfo().ToLiveUnmanaged() }; - private readonly Bindable configBindable = new Bindable(); - private static readonly Live random_skin_info = new SkinInfo { ID = SkinInfo.RANDOM_SKIN, @@ -56,13 +52,14 @@ namespace osu.Game.Overlays.Settings.Sections private IDisposable realmSubscription; [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) + private void load([CanBeNull] SkinEditorOverlay skinEditor) { Children = new Drawable[] { skinDropdown = new SkinSettingsDropdown { LabelText = SkinSettingsStrings.CurrentSkin, + Current = skins.CurrentSkinInfo, Keywords = new[] { @"skins" } }, new SettingsButton @@ -73,47 +70,27 @@ namespace osu.Game.Overlays.Settings.Sections new ExportSkinButton(), new DeleteSkinButton(), }; - - config.BindWith(OsuSetting.Skin, configBindable); } protected override void LoadComplete() { base.LoadComplete(); - skinDropdown.Current = dropdownBindable; - realmSubscription = realm.RegisterForNotifications(_ => realm.Realm.All() .Where(s => !s.DeletePending) - .OrderByDescending(s => s.Protected) // protected skins should be at the top. - .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged); + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged); - configBindable.BindValueChanged(_ => Scheduler.AddOnce(updateSelectedSkinFromConfig)); - - dropdownBindable.BindValueChanged(dropdownSelectionChanged); - } - - private void dropdownSelectionChanged(ValueChangedEvent> skin) - { - // Only handle cases where it's clear the user has intent to change skins. - if (skin.OldValue == null) return; - - if (skin.NewValue.Equals(random_skin_info)) + skinDropdown.Current.BindValueChanged(skin => { - var skinBefore = skins.CurrentSkinInfo.Value; - - skins.SelectRandomSkin(); - - if (skinBefore == skins.CurrentSkinInfo.Value) + if (skin.NewValue == random_skin_info) { - // the random selection didn't change the skin, so we should manually update the dropdown to match. - dropdownBindable.Value = skins.CurrentSkinInfo.Value; + // before selecting random, set the skin back to the previous selection. + // this is done because at this point it will be random_skin_info, and would + // cause SelectRandomSkin to be unable to skip the previous selection. + skins.CurrentSkinInfo.Value = skin.OldValue; + skins.SelectRandomSkin(); } - - return; - } - - configBindable.Value = skin.NewValue.ID.ToString(); + }); } private void skinsChanged(IRealmCollection sender, ChangeSet changes, Exception error) @@ -123,34 +100,20 @@ namespace osu.Game.Overlays.Settings.Sections if (!sender.Any()) return; - int protectedCount = sender.Count(s => s.Protected); - // For simplicity repopulate the full list. // In the future we should change this to properly handle ChangeSet events. dropdownItems.Clear(); - foreach (var skin in sender) + + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm)); + + dropdownItems.Add(random_skin_info); + + foreach (var skin in sender.Where(s => !s.Protected)) dropdownItems.Add(skin.ToLive(realm)); - dropdownItems.Insert(protectedCount, random_skin_info); - Schedule(() => - { - skinDropdown.Items = dropdownItems; - - updateSelectedSkinFromConfig(); - }); - } - - private void updateSelectedSkinFromConfig() - { - if (!skinDropdown.Items.Any()) - return; - - Live skin = null; - - if (Guid.TryParse(configBindable.Value, out var configId)) - skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId); - - dropdownBindable.Value = skin ?? skinDropdown.Items.First(); + Schedule(() => skinDropdown.Items = dropdownItems); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index c6a4cbbcaa..2c4832c68a 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -16,6 +16,11 @@ namespace osu.Game.Overlays.Settings [Resolved] protected OverlayColourProvider ColourProvider { get; private set; } + protected SidebarButton() + : base(HoverSampleSet.ButtonSidebar) + { + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 548d75c9a9..6dd9e2a56d 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -1,8 +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 - +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Extensions.EnumExtensions; @@ -23,25 +22,38 @@ namespace osu.Game.Overlays { public class SettingsToolboxGroup : Container, IExpandable { + private readonly string title; public const int CONTAINER_WIDTH = 270; private const float transition_duration = 250; - private const int border_thickness = 2; private const int header_height = 30; private const int corner_radius = 5; - private const float fade_duration = 800; - private const float inactive_alpha = 0.5f; - private readonly Cached headerTextVisibilityCache = new Cached(); - private readonly FillFlowContainer content; + protected override Container Content => content; + + private readonly FillFlowContainer content = new FillFlowContainer + { + Name = @"Content", + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Top = 5, Bottom = 10 }, + Spacing = new Vector2(0, 15), + }; public BindableBool Expanded { get; } = new BindableBool(true); - private readonly OsuSpriteText headerText; + private OsuSpriteText headerText = null!; - private readonly Container headerContent; + private Container headerContent = null!; + + private Box background = null!; + + private IconButton expandButton = null!; /// /// Create a new instance. @@ -49,20 +61,25 @@ namespace osu.Game.Overlays /// The title to be displayed in the header of this group. public SettingsToolboxGroup(string title) { + this.title = title; + AutoSizeAxes = Axes.Y; Width = CONTAINER_WIDTH; Masking = true; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) + { CornerRadius = corner_radius; - BorderColour = Color4.Black; - BorderThickness = border_thickness; InternalChildren = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.5f, + Alpha = 0.1f, + Colour = colourProvider?.Background4 ?? Color4.Black, }, new FillFlowContainer { @@ -88,7 +105,7 @@ namespace osu.Game.Overlays Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), Padding = new MarginPadding { Left = 10, Right = 30 }, }, - new IconButton + expandButton = new IconButton { Origin = Anchor.Centre, Anchor = Anchor.CentreRight, @@ -99,19 +116,7 @@ namespace osu.Game.Overlays }, } }, - content = new FillFlowContainer - { - Name = @"Content", - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeDuration = transition_duration, - AutoSizeEasing = Easing.OutQuint, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(15), - Spacing = new Vector2(0, 15), - } + content } }, }; @@ -175,9 +180,10 @@ namespace osu.Game.Overlays private void updateFadeState() { - this.FadeTo(IsHovered ? 1 : inactive_alpha, fade_duration, Easing.OutQuint); - } + const float fade_duration = 500; - protected override Container Content => content; + background.FadeTo(IsHovered ? 1 : 0.1f, fade_duration, Easing.OutQuint); + expandButton.FadeTo(IsHovered ? 1 : 0, fade_duration, Easing.OutQuint); + } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index a93ba17c5b..4ebd19a1f7 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -1,17 +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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Resources.Localisation.Web; using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; @@ -20,59 +22,103 @@ namespace osu.Game.Overlays.Toolbar { public class ToolbarUserButton : ToolbarOverlayToggleButton { - private readonly UpdateableAvatar avatar; + private UpdateableAvatar avatar = null!; - [Resolved] - private IAPIProvider api { get; set; } + private IBindable localUser = null!; - private readonly IBindable apiState = new Bindable(); + private LoadingSpinner spinner = null!; + + private SpriteIcon failingIcon = null!; + + private IBindable apiState = null!; public ToolbarUserButton() { AutoSizeAxes = Axes.X; + } - DrawableText.Font = OsuFont.GetFont(italics: true); - + [BackgroundDependencyLoader] + private void load(OsuColour colours, IAPIProvider api, LoginOverlay? login) + { Add(new OpaqueBackground { Depth = 1 }); - Flow.Add(avatar = new UpdateableAvatar(isInteractive: false) + Flow.Add(new Container { Masking = true, + CornerRadius = 4, Size = new Vector2(32), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - CornerRadius = 4, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, Radius = 4, Colour = Color4.Black.Opacity(0.1f), + }, + Children = new Drawable[] + { + avatar = new UpdateableAvatar(isInteractive: false) + { + RelativeSizeAxes = Axes.Both, + }, + spinner = new LoadingLayer(dimBackground: true, withBox: false, blockInput: false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + failingIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Size = new Vector2(0.3f), + Icon = FontAwesome.Solid.ExclamationTriangle, + RelativeSizeAxes = Axes.Both, + Colour = colours.YellowLight, + }, } }); - } - [BackgroundDependencyLoader(true)] - private void load(LoginOverlay login) - { - apiState.BindTo(api.State); + apiState = api.State.GetBoundCopy(); apiState.BindValueChanged(onlineStateChanged, true); + localUser = api.LocalUser.GetBoundCopy(); + localUser.BindValueChanged(userChanged, true); + StateContainer = login; } + private void userChanged(ValueChangedEvent user) => Schedule(() => + { + Text = user.NewValue.Username; + avatar.User = user.NewValue; + }); + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => { + failingIcon.FadeTo(state.NewValue == APIState.Failing ? 1 : 0, 200, Easing.OutQuint); + switch (state.NewValue) { - default: - Text = UsersStrings.AnonymousUsername; - avatar.User = new APIUser(); + case APIState.Connecting: + TooltipText = ToolbarStrings.Connecting; + spinner.Show(); break; - case APIState.Online: - Text = api.LocalUser.Value.Username; - avatar.User = api.LocalUser.Value; + case APIState.Failing: + TooltipText = ToolbarStrings.AttemptingToReconnect; + spinner.Show(); break; + + case APIState.Offline: + case APIState.Online: + TooltipText = string.Empty; + spinner.Hide(); + break; + + default: + throw new ArgumentOutOfRangeException(); } }); } diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs index 7754c1d450..15c455416c 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -4,6 +4,7 @@ #nullable disable using System.Linq; +using Markdig.Extensions.CustomContainers; using Markdig.Extensions.Yaml; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -15,6 +16,9 @@ namespace osu.Game.Overlays.Wiki.Markdown { public class WikiMarkdownContainer : OsuMarkdownContainer { + protected override bool Footnotes => true; + protected override bool CustomContainers => true; + public string CurrentPath { set => DocumentUrl = value; @@ -24,6 +28,11 @@ namespace osu.Game.Overlays.Wiki.Markdown { switch (markdownObject) { + case CustomContainer: + // infoboxes are parsed into CustomContainer objects, but we don't have support for infoboxes yet. + // todo: add support for infobox. + break; + case YamlFrontMatterBlock yamlFrontMatterBlock: container.Add(new WikiNoticeContainer(yamlFrontMatterBlock)); break; diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 3c5cb82a88..148d2977c7 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -100,6 +100,11 @@ namespace osu.Game.Overlays private void onPathChanged(ValueChangedEvent e) { + // the path could change as a result of redirecting to a newer location of the same page. + // we already have the correct wiki data, so we can safely return here. + if (e.NewValue == wikiData.Value?.Path) + return; + cancellationToken?.Cancel(); request?.Cancel(); @@ -121,6 +126,7 @@ namespace osu.Game.Overlays private void onSuccess(APIWikiPage response) { wikiData.Value = response; + path.Value = response.Path; if (response.Layout == index_path) { diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index f6abf259e8..f345504ca1 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -46,6 +46,10 @@ namespace osu.Game.Replays.Legacy [IgnoreMember] public bool MouseRight2 => ButtonState.HasFlagFast(ReplayButtonState.Right2); + [JsonIgnore] + [IgnoreMember] + public bool Smoke => ButtonState.HasFlagFast(ReplayButtonState.Smoke); + [Key(3)] public ReplayButtonState ButtonState; diff --git a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs index 5a03d66b84..4ff4f66665 100644 --- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs @@ -58,6 +58,9 @@ namespace osu.Game.Rulesets.Configuration pendingWrites.Clear(); } + if (!changed.Any()) + return true; + realm?.Write(r => { foreach (var c in changed) diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 9c58a53b97..8dd1b51cae 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -34,6 +34,11 @@ namespace osu.Game.Rulesets.Difficulty private readonly IRulesetInfo ruleset; private readonly IWorkingBeatmap beatmap; + /// + /// A yymmdd version which is used to discern when reprocessing is required. + /// + public virtual int Version => 0; + protected DifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { this.ruleset = ruleset; diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index 4465f1a328..3fb12041d1 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Difficulty // calculate total score ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = perfectPlay.Mods; - perfectPlay.TotalScore = (long)scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, perfectPlay); + perfectPlay.TotalScore = (long)scoreProcessor.ComputeScore(ScoringMode.Standardised, perfectPlay); // compute rank achieved // default to SS, then adjust the rank with mods diff --git a/osu.Game/Rulesets/EFRulesetInfo.cs b/osu.Game/Rulesets/EFRulesetInfo.cs deleted file mode 100644 index bd67bdb93c..0000000000 --- a/osu.Game/Rulesets/EFRulesetInfo.cs +++ /dev/null @@ -1,87 +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 System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; -using Newtonsoft.Json; -using osu.Framework.Testing; - -namespace osu.Game.Rulesets -{ - [ExcludeFromDynamicCompile] - [Table(@"RulesetInfo")] - public sealed class EFRulesetInfo : IEquatable, IComparable, IRulesetInfo - { - public int? ID { get; set; } - - public string Name { get; set; } - - public string ShortName { get; set; } - - public string InstantiationInfo { get; set; } - - [JsonIgnore] - public bool Available { get; set; } - - // TODO: this should probably be moved to RulesetStore. - public Ruleset CreateInstance() - { - if (!Available) - return null; - - var type = Type.GetType(InstantiationInfo); - - if (type == null) - return null; - - var ruleset = Activator.CreateInstance(type) as Ruleset; - - return ruleset; - } - - public bool Equals(EFRulesetInfo other) => other != null && ID == other.ID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; - - public int CompareTo(EFRulesetInfo other) => OnlineID.CompareTo(other.OnlineID); - - public int CompareTo(IRulesetInfo other) - { - if (!(other is EFRulesetInfo ruleset)) - throw new ArgumentException($@"Object is not of type {nameof(EFRulesetInfo)}.", nameof(other)); - - return CompareTo(ruleset); - } - - public override bool Equals(object obj) => obj is EFRulesetInfo rulesetInfo && Equals(rulesetInfo); - - public bool Equals(IRulesetInfo other) => other is RulesetInfo b && Equals(b); - - [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] - public override int GetHashCode() - { - unchecked - { - int hashCode = ID.HasValue ? ID.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ (InstantiationInfo != null ? InstantiationInfo.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (Name != null ? Name.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ Available.GetHashCode(); - return hashCode; - } - } - - public override string ToString() => Name ?? $"{Name} ({ShortName}) ID: {ID}"; - - #region Implementation of IHasOnlineID - - [NotMapped] - public int OnlineID - { - get => ID ?? -1; - set => ID = value >= 0 ? value : null; - } - - #endregion - } -} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs index 93fb7b9ab8..a66c00d78b 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; +using osu.Game.Beatmaps; using osu.Game.IO.FileAbstraction; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Storyboards; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs index b15a9f5331..be0c4501e7 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Rulesets.Edit.Checks diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs index 4726211666..d23d8ad438 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs @@ -8,6 +8,8 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; @@ -52,20 +54,32 @@ namespace osu.Game.Rulesets.Edit } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) + AddInternal(new Container { - Padding = new MarginPadding(10), - Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Child = new EditorToolboxGroup("snapping") + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] { - Child = distanceSpacingSlider = new ExpandableSlider> + new Box { - Current = { BindTarget = DistanceSpacingMultiplier }, - KeyboardStep = adjust_step, + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) + { + Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, + Child = new EditorToolboxGroup("snapping") + { + Child = distanceSpacingSlider = new ExpandableSlider> + { + Current = { BindTarget = DistanceSpacingMultiplier }, + KeyboardStep = adjust_step, + } + } } } }); @@ -91,7 +105,7 @@ namespace osu.Game.Rulesets.Edit } } - public bool OnPressed(KeyBindingPressEvent e) + public virtual bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { @@ -103,7 +117,7 @@ namespace osu.Game.Rulesets.Edit return false; } - public void OnReleased(KeyBindingReleaseEvent e) + public virtual void OnReleased(KeyBindingReleaseEvent e) { } @@ -134,7 +148,7 @@ namespace osu.Game.Rulesets.Edit public virtual float GetBeatSnapDistanceAt(HitObject referenceObject) { - return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * referenceObject.DifficultyControlPoint.SliderVelocity / BeatSnapProvider.BeatDivisor); + return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * 1 / BeatSnapProvider.BeatDivisor); } public virtual float DurationToDistance(HitObject referenceObject, double duration) diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index d3371d3543..26dd5dfa55 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -19,7 +19,8 @@ namespace osu.Game.Rulesets.Edit { RelativeSizeAxes = Axes.Y; - FillFlow.Spacing = new Vector2(10); + FillFlow.Spacing = new Vector2(5); + Padding = new MarginPadding { Vertical = 5 }; } protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 37c66037eb..3bed835854 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -12,10 +12,12 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -80,7 +82,7 @@ namespace osu.Game.Rulesets.Edit dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { Config = Dependencies.Get().GetConfigFor(Ruleset); @@ -102,7 +104,7 @@ namespace osu.Game.Rulesets.Edit InternalChildren = new Drawable[] { - new Container + PlayfieldContentContainer = new Container { Name = "Content", RelativeSizeAxes = Axes.Both, @@ -116,25 +118,37 @@ namespace osu.Game.Rulesets.Edit .WithChild(BlueprintContainer = CreateBlueprintContainer()) } }, - new ExpandingToolboxContainer(90, 200) + new Container { - Padding = new MarginPadding(10), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, Children = new Drawable[] { - new EditorToolboxGroup("toolbox (1-9)") + new Box { - Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, }, - new EditorToolboxGroup("toggles (Q~P)") + new ExpandingToolboxContainer(60, 200) { - Child = togglesCollection = new FillFlowContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - }, - } + new EditorToolboxGroup("toolbox (1-9)") + { + Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } + }, + new EditorToolboxGroup("toggles (Q~P)") + { + Child = togglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, + } + } + }, } }, }; @@ -152,6 +166,15 @@ namespace osu.Game.Rulesets.Edit EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } + /// + /// Houses all content relevant to the playfield. + /// + /// + /// Generally implementations should not be adding to this directly. + /// Use or instead. + /// + protected Container PlayfieldContentContainer { get; private set; } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Rulesets/ILegacyRuleset.cs b/osu.Game/Rulesets/ILegacyRuleset.cs index a8cfed4866..f4b03baccd 100644 --- a/osu.Game/Rulesets/ILegacyRuleset.cs +++ b/osu.Game/Rulesets/ILegacyRuleset.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 - namespace osu.Game.Rulesets { public interface ILegacyRuleset diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 0d8b8429d8..7e1196d4ca 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -165,7 +165,7 @@ namespace osu.Game.Rulesets.Judgements // sub-classes might have added their own children that would be removed here if .InternalChild was used. if (JudgementBody != null) - RemoveInternal(JudgementBody); + RemoveInternal(JudgementBody, true); AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponent(type), _ => CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling) diff --git a/osu.Game/Rulesets/Mods/DifficultyBindable.cs b/osu.Game/Rulesets/Mods/DifficultyBindable.cs index eb5f97bcf7..34e9fe40a3 100644 --- a/osu.Game/Rulesets/Mods/DifficultyBindable.cs +++ b/osu.Game/Rulesets/Mods/DifficultyBindable.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; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -29,7 +27,7 @@ namespace osu.Game.Rulesets.Mods /// /// A function that can extract the current value of this setting from a beatmap difficulty for display purposes. /// - public Func ReadCurrentFromDifficulty; + public Func? ReadCurrentFromDifficulty; public float Precision { diff --git a/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.cs b/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.cs index 9286f682d1..d45311675d 100644 --- a/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.cs +++ b/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.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.Game.Beatmaps; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs index d0b54f835b..8c99d739cb 100644 --- a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs +++ b/osu.Game/Rulesets/Mods/IApplicableFailOverride.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 - namespace osu.Game.Rulesets.Mods { /// diff --git a/osu.Game/Rulesets/Mods/IApplicableMod.cs b/osu.Game/Rulesets/Mods/IApplicableMod.cs index 7675bd89ef..8ca1a3f8a5 100644 --- a/osu.Game/Rulesets/Mods/IApplicableMod.cs +++ b/osu.Game/Rulesets/Mods/IApplicableMod.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 - namespace osu.Game.Rulesets.Mods { /// diff --git a/osu.Game/Rulesets/Mods/IApplicableToAudio.cs b/osu.Game/Rulesets/Mods/IApplicableToAudio.cs index de76790aee..901da7af55 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToAudio.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToAudio.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 - namespace osu.Game.Rulesets.Mods { public interface IApplicableToAudio : IApplicableToTrack, IApplicableToSample diff --git a/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs b/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs index 278b4794c5..cff669bf53 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToBeatmap.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.Game.Beatmaps; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToBeatmapConverter.cs b/osu.Game/Rulesets/Mods/IApplicableToBeatmapConverter.cs index a5ccea1873..8cefb02904 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToBeatmapConverter.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToBeatmapConverter.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.Game.Beatmaps; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs index c653a674ef..e23a5d8d99 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.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.Game.Beatmaps; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs index 1447511de9..42b520ab26 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.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.Game.Beatmaps; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs index f559ed04d7..c8a9ff2f9a 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.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.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs index 8bf2c3810e..7f926dd8b8 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.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; using System.Collections.Generic; using osu.Framework.Extensions.IEnumerableExtensions; diff --git a/osu.Game/Rulesets/Mods/IApplicableToDrawableRuleset.cs b/osu.Game/Rulesets/Mods/IApplicableToDrawableRuleset.cs index ace3af62a1..b012beb0c0 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDrawableRuleset.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDrawableRuleset.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.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; diff --git a/osu.Game/Rulesets/Mods/IApplicableToHUD.cs b/osu.Game/Rulesets/Mods/IApplicableToHUD.cs index b5fe299b24..4fb535a0b3 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToHUD.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToHUD.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.Game.Screens.Play; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs index a58f8640fd..2676060efa 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.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.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToHitObject.cs b/osu.Game/Rulesets/Mods/IApplicableToHitObject.cs index d9fa993393..f7f81c92c0 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToHitObject.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToHitObject.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.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToPlayer.cs b/osu.Game/Rulesets/Mods/IApplicableToPlayer.cs index c28935607f..bf78428470 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToPlayer.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToPlayer.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.Game.Screens.Play; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToRate.cs b/osu.Game/Rulesets/Mods/IApplicableToRate.cs index c66c8f49a1..f613867132 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToRate.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToRate.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 - namespace osu.Game.Rulesets.Mods { /// diff --git a/osu.Game/Rulesets/Mods/IApplicableToSample.cs b/osu.Game/Rulesets/Mods/IApplicableToSample.cs index 97ed0fbf7e..efd88f2399 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToSample.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToSample.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.Audio; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs index 24c1ac9afe..b93e50921f 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.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.Game.Rulesets.Scoring; using osu.Game.Scoring; diff --git a/osu.Game/Rulesets/Mods/IApplicableToTrack.cs b/osu.Game/Rulesets/Mods/IApplicableToTrack.cs index 358ef71cc0..deecd4bf1f 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToTrack.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToTrack.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.Audio; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/ICreateReplay.cs b/osu.Game/Rulesets/Mods/ICreateReplay.cs index e77f4c49b9..1e5eeca92c 100644 --- a/osu.Game/Rulesets/Mods/ICreateReplay.cs +++ b/osu.Game/Rulesets/Mods/ICreateReplay.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; using System.Collections.Generic; using osu.Game.Beatmaps; diff --git a/osu.Game/Rulesets/Mods/ICreateReplayData.cs b/osu.Game/Rulesets/Mods/ICreateReplayData.cs index 6058380eb3..c13e65c7b8 100644 --- a/osu.Game/Rulesets/Mods/ICreateReplayData.cs +++ b/osu.Game/Rulesets/Mods/ICreateReplayData.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 osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; @@ -45,7 +43,7 @@ namespace osu.Game.Rulesets.Mods /// public readonly ModCreatedUser User; - public ModReplayData(Replay replay, ModCreatedUser user = null) + public ModReplayData(Replay replay, ModCreatedUser? user = null) { Replay = replay; User = user ?? new ModCreatedUser(); @@ -58,6 +56,7 @@ namespace osu.Game.Rulesets.Mods public class ModCreatedUser : IUser { public int OnlineID => APIUser.SYSTEM_USER_ID; + public CountryCode CountryCode => default; public bool IsBot => true; public string Username { get; set; } = string.Empty; diff --git a/osu.Game/Rulesets/Mods/IHasSeed.cs b/osu.Game/Rulesets/Mods/IHasSeed.cs index fd2161ac09..001a9d214c 100644 --- a/osu.Game/Rulesets/Mods/IHasSeed.cs +++ b/osu.Game/Rulesets/Mods/IHasSeed.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.Bindables; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 349cc7dd5a..1f9e26c9d7 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -1,10 +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 System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -23,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/IReadFromConfig.cs b/osu.Game/Rulesets/Mods/IReadFromConfig.cs index ee6fb6364f..d66fabce70 100644 --- a/osu.Game/Rulesets/Mods/IReadFromConfig.cs +++ b/osu.Game/Rulesets/Mods/IReadFromConfig.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.Game.Configuration; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs b/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs index 3aad858af5..7cf480a11b 100644 --- a/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs +++ b/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.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.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods diff --git a/osu.Game/Rulesets/Mods/MetronomeBeat.cs b/osu.Game/Rulesets/Mods/MetronomeBeat.cs index b26052a37e..149af1e30a 100644 --- a/osu.Game/Rulesets/Mods/MetronomeBeat.cs +++ b/osu.Game/Rulesets/Mods/MetronomeBeat.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.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 17093e3033..e4c91d3037 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.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; using System.Collections.Generic; using System.Linq; @@ -11,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; @@ -36,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 . @@ -117,7 +116,7 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual Type[] IncompatibleMods => Array.Empty(); - private IReadOnlyList settingsBacking; + private IReadOnlyList? settingsBacking; /// /// A list of the all settings within this mod. @@ -128,6 +127,11 @@ namespace osu.Game.Rulesets.Mods .Cast() .ToList(); + /// + /// Whether all settings in this mod are set to their default state. + /// + protected virtual bool UsesDefaultConfiguration => Settings.All(s => s.IsDefault); + /// /// Creates a copy of this initialised to a default state. /// @@ -216,8 +220,8 @@ namespace osu.Game.Rulesets.Mods public bool Equals(IBindable x, IBindable y) { - object xValue = x?.GetUnderlyingSettingValue(); - object yValue = y?.GetUnderlyingSettingValue(); + object xValue = x.GetUnderlyingSettingValue(); + object yValue = y.GetUnderlyingSettingValue(); return EqualityComparer.Default.Equals(xValue, yValue); } diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index aea6e12a07..e7127abcf0 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -1,13 +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.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; @@ -25,44 +24,36 @@ 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; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.5; public override bool ValidForMultiplayer => false; public override bool ValidForMultiplayerAsFreeMod => false; - public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp), typeof(ModAutoplay) }; [SettingSource("Initial rate", "The starting speed of the track")] - public BindableNumber InitialRate { get; } = new BindableDouble + public BindableNumber InitialRate { get; } = new BindableDouble(1) { MinValue = 0.5, MaxValue = 2, - Default = 1, - Value = 1, Precision = 0.01 }; [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public BindableBool AdjustPitch { get; } = new BindableBool(true); /// /// The instantaneous rate of the track. /// Every frame this mod will attempt to smoothly adjust this to meet . /// - public BindableNumber SpeedChange { get; } = new BindableDouble + public BindableNumber SpeedChange { get; } = new BindableDouble(1) { MinValue = min_allowable_rate, MaxValue = max_allowable_rate, - Default = 1, - Value = 1 }; // The two constants below denote the maximum allowable range of rates that `SpeedChange` can take. @@ -79,7 +70,7 @@ namespace osu.Game.Rulesets.Mods // Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast. private const double rate_change_on_miss = 0.95d; - private IAdjustableAudioComponent track; + private IAdjustableAudioComponent? track; private double targetRate = 1d; /// diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 6d786fc8e2..6cafe0716d 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -1,11 +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 System.Collections.Generic; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Replays; @@ -19,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; @@ -30,7 +29,7 @@ namespace osu.Game.Rulesets.Mods public override bool ValidForMultiplayer => false; public override bool ValidForMultiplayerAsFreeMod => false; - public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) }; + public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAdaptiveSpeed) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index bd0f2bfe59..0c301d293f 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -1,12 +1,11 @@ // 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.Extensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; @@ -36,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/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs index a6a8244480..cdfb36ebbc 100644 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ b/osu.Game/Rulesets/Mods/ModBlockFail.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.Bindables; using osu.Game.Configuration; using osu.Game.Screens.Play; @@ -11,7 +9,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModBlockFail : Mod, IApplicableFailOverride, IApplicableToHUD, IReadFromConfig { - private Bindable showHealthBar; + private readonly Bindable showHealthBar = new Bindable(); /// /// We never fail, 'yo. @@ -22,7 +20,7 @@ namespace osu.Game.Rulesets.Mods public void ReadFromConfig(OsuConfigManager config) { - showHealthBar = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail); + config.BindWith(OsuSetting.ShowHealthDisplayWhenCantFail, showHealthBar); } public void ApplyToHUD(HUDOverlay overlay) diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index 6e7bd6350e..ae661c5f25 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -1,11 +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 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; @@ -29,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 b4885ff16e..55b16297e2 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -1,9 +1,8 @@ // 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.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -17,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 262aeb07ac..de1a5ab56c 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -1,11 +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 osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -14,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 c0e2c75aca..f4c6be4f77 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -1,12 +1,11 @@ // 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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -16,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"; @@ -24,7 +23,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => FontAwesome.Solid.Hammer; - public override double ScoreMultiplier => 1.0; + public override double ScoreMultiplier => 0.5; public override bool RequiresConfiguration => true; diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 389d0db261..9e4469bf25 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -1,10 +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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -16,15 +15,13 @@ 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 + public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) { MinValue = 1.01, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; } diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index bc5988174b..0f51e2a6d5 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.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; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; diff --git a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs index 984892de51..c4396e440e 100644 --- a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs +++ b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.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 Humanizer; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -24,7 +22,7 @@ namespace osu.Game.Rulesets.Mods private int retries; - private BindableNumber health; + private readonly BindableNumber health = new BindableDouble(); public override void ApplyToDifficulty(BeatmapDifficulty difficulty) { @@ -46,7 +44,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToHealthProcessor(HealthProcessor healthProcessor) { - health = healthProcessor.Health.GetBoundCopy(); + health.BindTo(healthProcessor.Health); } } } diff --git a/osu.Game/Rulesets/Mods/ModExtensions.cs b/osu.Game/Rulesets/Mods/ModExtensions.cs index ad61404972..b22030414b 100644 --- a/osu.Game/Rulesets/Mods/ModExtensions.cs +++ b/osu.Game/Rulesets/Mods/ModExtensions.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 osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs index e24746ebd9..4425ece513 100644 --- a/osu.Game/Rulesets/Mods/ModFailCondition.cs +++ b/osu.Game/Rulesets/Mods/ModFailCondition.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; using osu.Framework.Bindables; using osu.Game.Configuration; diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 649d5480bf..d58a901154 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -1,20 +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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps.Timing; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.OpenGL.Vertices; @@ -22,6 +18,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -33,7 +30,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; } @@ -86,8 +83,6 @@ namespace osu.Game.Rulesets.Mods flashlight.Combo.BindTo(Combo); drawableRuleset.KeyBindingInputManager.Add(flashlight); - - flashlight.Breaks = drawableRuleset.Beatmap.Breaks; } protected abstract Flashlight CreateFlashlight(); @@ -96,14 +91,12 @@ namespace osu.Game.Rulesets.Mods { public readonly BindableInt Combo = new BindableInt(); - private IShader shader; + private IShader shader = null!; protected override DrawNode CreateDrawNode() => new FlashlightDrawNode(this); public override bool RemoveCompletedTransforms => false; - public List Breaks; - private readonly float defaultFlashlightSize; private readonly float sizeMultiplier; private readonly bool comboBasedSize; @@ -121,46 +114,50 @@ namespace osu.Game.Rulesets.Mods shader = shaderManager.Load("PositionAndColour", FragmentShader); } + [Resolved] + private Player? player { get; set; } + + private readonly IBindable isBreakTime = new BindableBool(); + protected override void LoadComplete() { base.LoadComplete(); - Combo.ValueChanged += OnComboChange; + Combo.ValueChanged += _ => UpdateFlashlightSize(GetSize()); - using (BeginAbsoluteSequence(0)) + if (player != null) { - foreach (var breakPeriod in Breaks) - { - if (!breakPeriod.HasEffect) - continue; - - if (breakPeriod.Duration < FLASHLIGHT_FADE_DURATION * 2) continue; - - this.Delay(breakPeriod.StartTime + FLASHLIGHT_FADE_DURATION).FadeOutFromOne(FLASHLIGHT_FADE_DURATION); - this.Delay(breakPeriod.EndTime - FLASHLIGHT_FADE_DURATION).FadeInFromZero(FLASHLIGHT_FADE_DURATION); - } + isBreakTime.BindTo(player.IsBreakTime); + isBreakTime.BindValueChanged(_ => UpdateFlashlightSize(GetSize()), true); } } - protected abstract void OnComboChange(ValueChangedEvent e); + protected abstract void UpdateFlashlightSize(float size); protected abstract string FragmentShader { get; } - protected float GetSizeFor(int combo) + protected float GetSize() { float size = defaultFlashlightSize * sizeMultiplier; - if (comboBasedSize) - { - if (combo > 200) - size *= 0.8f; - else if (combo > 100) - size *= 0.9f; - } + if (isBreakTime.Value) + size *= 2.5f; + else if (comboBasedSize) + size *= GetComboScaleFor(Combo.Value); return size; } + protected virtual float GetComboScaleFor(int combo) + { + if (combo >= 200) + return 0.625f; + if (combo >= 100) + return 0.8125f; + + return 1.0f; + } + private Vector2 flashlightPosition; protected Vector2 FlashlightPosition @@ -203,27 +200,37 @@ namespace osu.Game.Rulesets.Mods } } + private float flashlightSmoothness = 1.1f; + + public float FlashlightSmoothness + { + get => flashlightSmoothness; + set + { + if (flashlightSmoothness == value) return; + + flashlightSmoothness = value; + Invalidate(Invalidation.DrawNode); + } + } + private class FlashlightDrawNode : DrawNode { protected new Flashlight Source => (Flashlight)base.Source; - private IShader shader; + private IShader shader = null!; private Quad screenSpaceDrawQuad; private Vector2 flashlightPosition; private Vector2 flashlightSize; private float flashlightDim; + private float flashlightSmoothness; - private readonly VertexBatch quadBatch = new QuadBatch(1, 1); - private readonly Action addAction; + private IVertexBatch? quadBatch; + private Action? addAction; public FlashlightDrawNode(Flashlight source) : base(source) { - addAction = v => quadBatch.Add(new PositionAndColourVertex - { - Position = v.Position, - Colour = v.Colour - }); } public override void ApplyState() @@ -235,19 +242,31 @@ namespace osu.Game.Rulesets.Mods flashlightPosition = Vector2Extensions.Transform(Source.FlashlightPosition, DrawInfo.Matrix); flashlightSize = Source.FlashlightSize * DrawInfo.Matrix.ExtractScale().Xy; flashlightDim = Source.FlashlightDim; + flashlightSmoothness = Source.flashlightSmoothness; } - public override void Draw(Action vertexAction) + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); + + if (quadBatch == null) + { + quadBatch = renderer.CreateQuadBatch(1, 1); + addAction = v => quadBatch.Add(new PositionAndColourVertex + { + Position = v.Position, + Colour = v.Colour + }); + } shader.Bind(); shader.GetUniform("flashlightPos").UpdateValue(ref flashlightPosition); shader.GetUniform("flashlightSize").UpdateValue(ref flashlightSize); shader.GetUniform("flashlightDim").UpdateValue(ref flashlightDim); + shader.GetUniform("flashlightSmoothness").UpdateValue(ref flashlightSmoothness); - DrawQuad(Texture.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour, vertexAction: addAction); + renderer.DrawQuad(renderer.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour, vertexAction: addAction); shader.Unbind(); } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 48fa7c13b4..7d858dca6f 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -1,10 +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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -16,15 +15,13 @@ 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 + public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) { MinValue = 0.5, MaxValue = 0.99, - Default = 0.75, - Value = 0.75, Precision = 0.01, }; } diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 030520fccc..2886e59c54 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -1,10 +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 System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -16,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/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 35107762aa..5a8226115f 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.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.Game.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game/Rulesets/Mods/ModMirror.cs b/osu.Game/Rulesets/Mods/ModMirror.cs index 00a1d4a9c6..3c4b7d0c60 100644 --- a/osu.Game/Rulesets/Mods/ModMirror.cs +++ b/osu.Game/Rulesets/Mods/ModMirror.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 - namespace osu.Game.Rulesets.Mods { public abstract class ModMirror : Mod diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs index 88e49f41b0..05ecd37000 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.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 osu.Framework.Audio; using osu.Framework.Bindables; @@ -24,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; } @@ -35,37 +33,23 @@ namespace osu.Game.Rulesets.Mods private readonly BindableNumber mainVolumeAdjust = new BindableDouble(0.5); private readonly BindableNumber metronomeVolumeAdjust = new BindableDouble(0.5); - private BindableNumber currentCombo; + private readonly BindableNumber currentCombo = new BindableInt(); [SettingSource("Enable metronome", "Add a metronome beat to help you keep track of the rhythm.")] - public BindableBool EnableMetronome { get; } = new BindableBool - { - Default = true, - Value = true - }; + public BindableBool EnableMetronome { get; } = new BindableBool(true); [SettingSource("Final volume at combo", "The combo count at which point the track reaches its final volume.", SettingControlType = typeof(SettingsSlider))] - public BindableInt MuteComboCount { get; } = new BindableInt + public BindableInt MuteComboCount { get; } = new BindableInt(100) { - Default = 100, - Value = 100, MinValue = 0, MaxValue = 500, }; [SettingSource("Start muted", "Increase volume as combo builds.")] - public BindableBool InverseMuting { get; } = new BindableBool - { - Default = false, - Value = false - }; + public BindableBool InverseMuting { get; } = new BindableBool(); [SettingSource("Mute hit sounds", "Hit sounds are also muted alongside the track.")] - public BindableBool AffectsHitSounds { get; } = new BindableBool - { - Default = true, - Value = true - }; + public BindableBool AffectsHitSounds { get; } = new BindableBool(true); protected ModMuted() { @@ -94,7 +78,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { - currentCombo = scoreProcessor.Combo.GetBoundCopy(); + currentCombo.BindTo(scoreProcessor.Combo); currentCombo.BindValueChanged(combo => { double dimFactor = MuteComboCount.Value == 0 ? 1 : (double)combo.NewValue / MuteComboCount.Value; diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index 7c2201cd98..099bf386f3 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -1,14 +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 osu.Framework.Allocation; using osu.Framework.Audio; 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; @@ -25,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 @@ -57,10 +56,10 @@ namespace osu.Game.Rulesets.Mods public class NightcoreBeatContainer : BeatSyncedContainer { - private PausableSkinnableSound hatSample; - private PausableSkinnableSound clapSample; - private PausableSkinnableSound kickSample; - private PausableSkinnableSound finishSample; + private PausableSkinnableSound? hatSample; + private PausableSkinnableSound? clapSample; + private PausableSkinnableSound? kickSample; + private PausableSkinnableSound? finishSample; private int? firstBeat; diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 27dbb53e6c..31bb4338b3 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -1,10 +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 System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods @@ -15,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 cc0b38cbc0..5dd4b317e7 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -1,9 +1,8 @@ // 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.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -14,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/ModNoScope.cs b/osu.Game/Rulesets/Mods/ModNoScope.cs index c43ac33b3f..36fbb88943 100644 --- a/osu.Game/Rulesets/Mods/ModNoScope.cs +++ b/osu.Game/Rulesets/Mods/ModNoScope.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 osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -30,26 +30,31 @@ namespace osu.Game.Rulesets.Mods protected const float TRANSITION_DURATION = 100; - protected BindableNumber CurrentCombo; + protected readonly BindableNumber CurrentCombo = new BindableInt(); - protected IBindable IsBreakTime; + protected readonly IBindable IsBreakTime = new Bindable(); protected float ComboBasedAlpha; + [SettingSource( + "Hidden at combo", + "The combo count at which the cursor becomes completely hidden", + SettingControlType = typeof(SettingsSlider) + )] public abstract BindableInt HiddenComboCount { get; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; public void ApplyToPlayer(Player player) { - IsBreakTime = player.IsBreakTime.GetBoundCopy(); + IsBreakTime.BindTo(player.IsBreakTime); } public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { if (HiddenComboCount.Value == 0) return; - CurrentCombo = scoreProcessor.Combo.GetBoundCopy(); + CurrentCombo.BindTo(scoreProcessor.Combo); CurrentCombo.BindValueChanged(combo => { ComboBasedAlpha = Math.Max(MIN_ALPHA, 1 - (float)combo.NewValue / HiddenComboCount.Value); diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index b6daf54fa0..804f23b6b7 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -1,11 +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 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; @@ -19,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/ModPreset.cs b/osu.Game/Rulesets/Mods/ModPreset.cs new file mode 100644 index 0000000000..2c3f574d47 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModPreset.cs @@ -0,0 +1,75 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Database; +using osu.Game.Online.API; +using Realms; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// A mod preset is a named collection of configured mods. + /// Presets are presented to the user in the mod select overlay for convenience. + /// + public class ModPreset : RealmObject, IHasGuidPrimaryKey, ISoftDelete + { + /// + /// The internal database ID of the preset. + /// + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + /// + /// The ruleset that the preset is valid for. + /// + public RulesetInfo Ruleset { get; set; } = null!; + + /// + /// The name of the mod preset. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The description of the mod preset. + /// + public string Description { get; set; } = string.Empty; + + /// + /// The set of configured mods that are part of the preset. + /// + [Ignored] + public ICollection Mods + { + get + { + if (string.IsNullOrEmpty(ModsJson)) + return Array.Empty(); + + var apiMods = JsonConvert.DeserializeObject>(ModsJson); + var ruleset = Ruleset.CreateInstance(); + return apiMods.AsNonNull().Select(mod => mod.ToMod(ruleset)).ToArray(); + } + set + { + var apiMods = value.Select(mod => new APIMod(mod)).ToArray(); + ModsJson = JsonConvert.SerializeObject(apiMods); + } + } + + /// + /// The set of configured mods that are part of the preset, serialised as a JSON blob. + /// + [MapTo("Mods")] + public string ModsJson { get; set; } = string.Empty; + + /// + /// Whether the preset has been soft-deleted by the user. + /// + public bool DeletePending { get; set; } + } +} diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs index 6654bff04b..178b9fb619 100644 --- a/osu.Game/Rulesets/Mods/ModRandom.cs +++ b/osu.Game/Rulesets/Mods/ModRandom.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.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; @@ -20,10 +18,6 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 1; [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] - public Bindable Seed { get; } = new Bindable - { - Default = null, - Value = null - }; + public Bindable Seed { get; } = new Bindable(); } } diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 643280623c..7b55ba4ad0 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.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; using osu.Framework.Audio; using osu.Framework.Bindables; diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs index 4829d10ddb..49c10339ee 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.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; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; @@ -15,7 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "RX"; public override IconUsage? Icon => OsuIcon.ModRelax; public override ModType Type => ModType.Automation; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.1; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }; } } diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index 48ab888a90..4e4e8662e8 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -1,11 +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 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; @@ -18,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/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index daa4b7c797..c4cb41fb6a 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.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; using System.Linq; using osu.Framework.Audio; @@ -21,6 +19,8 @@ namespace osu.Game.Rulesets.Mods /// public const double FINAL_RATE_PROGRESS = 0.75f; + public override double ScoreMultiplier => 0.5; + [SettingSource("Initial rate", "The starting speed of the track")] public abstract BindableNumber InitialRate { get; } @@ -39,14 +39,12 @@ namespace osu.Game.Rulesets.Mods private double finalRateTime; private double beginRampTime; - public BindableNumber SpeedChange { get; } = new BindableDouble + public BindableNumber SpeedChange { get; } = new BindableDouble(1) { - Default = 1, - Value = 1, Precision = 0.01, }; - private IAdjustableAudioComponent track; + private IAdjustableAudioComponent? track; protected ModTimeRamp() { diff --git a/osu.Game/Rulesets/Mods/ModType.cs b/osu.Game/Rulesets/Mods/ModType.cs index 5a405b7632..e3c82e42f5 100644 --- a/osu.Game/Rulesets/Mods/ModType.cs +++ b/osu.Game/Rulesets/Mods/ModType.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 - namespace osu.Game.Rulesets.Mods { public enum ModType diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 2150264a0c..35a673093b 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -1,13 +1,11 @@ // 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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -15,36 +13,24 @@ 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; - [SettingSource("Initial rate", "The starting speed of the track")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1) { MinValue = 0.51, MaxValue = 2, - Default = 1, - Value = 1, Precision = 0.01, }; - [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(0.75) { MinValue = 0.5, MaxValue = 1.99, - Default = 0.75, - Value = 0.75, Precision = 0.01, }; - [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindUp)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index d9ee561ef8..bbc8382055 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -1,13 +1,11 @@ // 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 osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -15,36 +13,24 @@ 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; - [SettingSource("Initial rate", "The starting speed of the track")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1) { MinValue = 0.5, MaxValue = 1.99, - Default = 1, - Value = 1, Precision = 0.01, }; - [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(1.5) { MinValue = 0.51, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; - [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs index d84695fff8..2e3619ec63 100644 --- a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs +++ b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.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 osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -21,7 +19,7 @@ namespace osu.Game.Rulesets.Mods /// /// The first adjustable object. /// - protected HitObject FirstObject { get; private set; } + protected HitObject? FirstObject { get; private set; } /// /// Whether the visibility of should be increased. @@ -59,7 +57,7 @@ namespace osu.Game.Rulesets.Mods { FirstObject = getFirstAdjustableObjectRecursive(beatmap.HitObjects); - HitObject getFirstAdjustableObjectRecursive(IReadOnlyList hitObjects) + HitObject? getFirstAdjustableObjectRecursive(IReadOnlyList hitObjects) { foreach (var h in hitObjects) { @@ -93,7 +91,7 @@ namespace osu.Game.Rulesets.Mods /// The to check. /// The which may be equal to or contain as a nested object. /// Whether is equal to or nested within . - private bool isObjectEqualToOrNestedIn(HitObject toCheck, HitObject target) + private bool isObjectEqualToOrNestedIn(HitObject toCheck, HitObject? target) { if (target == null) return false; diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index d62dc34d3b..9fbc0ddd97 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -1,10 +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 System; using System.Linq; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -12,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 e058fba566..abe05996ff 100644 --- a/osu.Game/Rulesets/Mods/UnknownMod.cs +++ b/osu.Game/Rulesets/Mods/UnknownMod.cs @@ -1,7 +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 +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -14,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/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs index dec81d9bbd..c2709db747 100644 --- a/osu.Game/Rulesets/Objects/BarLineGenerator.cs +++ b/osu.Game/Rulesets/Objects/BarLineGenerator.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; using System.Collections.Generic; using System.Linq; @@ -40,14 +38,21 @@ namespace osu.Game.Rulesets.Objects for (int i = 0; i < timingPoints.Count; i++) { TimingControlPoint currentTimingPoint = timingPoints[i]; + EffectControlPoint currentEffectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTimingPoint.Time); int currentBeat = 0; - // Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object - double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - currentTimingPoint.BeatLength : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; + // Stop on the next timing point, or if there is no next timing point stop slightly past the last object + double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; + double startTime = currentTimingPoint.Time; double barLength = currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; - for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++) + if (currentEffectPoint.OmitFirstBarLine) + { + startTime += barLength; + } + + for (double t = startTime; Precision.AlmostBigger(endTime, t); t += barLength, currentBeat++) { double roundedTime = Math.Round(t, MidpointRounding.AwayFromZero); diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 39ccaa2e5d..dec68a6c22 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -172,7 +172,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { config.BindWith(OsuSetting.PositionalHitsoundsLevel, positionalHitsoundsLevel); - // Explicit non-virtual function call. + // Explicit non-virtual function call in case a DrawableHitObject overrides AddInternal. base.AddInternal(Samples = new PausableSkinnableSound()); CurrentSkin = skinSource; @@ -405,7 +405,10 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public event Action ApplyCustomUpdateState; - protected override void ClearInternal(bool disposeChildren = true) => throw new InvalidOperationException($"Should never clear a {nameof(DrawableHitObject)}"); + protected override void ClearInternal(bool disposeChildren = true) => + // See sample addition in load method. + throw new InvalidOperationException( + $"Should never clear a {nameof(DrawableHitObject)} as the base implementation adds components. If attempting to use {nameof(InternalChild)} or {nameof(InternalChildren)}, using {nameof(AddInternal)} or {nameof(AddRangeInternal)} instead."); private void updateState(ArmedState newState, bool force = false) { @@ -648,7 +651,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// This does not affect the time offset provided to invocations of . /// - protected virtual double MaximumJudgementOffset => HitObject.HitWindows?.WindowFor(HitResult.Miss) ?? 0; + public virtual double MaximumJudgementOffset => HitObject.HitWindows?.WindowFor(HitResult.Miss) ?? 0; /// /// Applies the of this , notifying responders such as diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index d20e0616e5..0f79e58201 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; @@ -198,6 +199,21 @@ namespace osu.Game.Rulesets.Objects /// [NotNull] protected virtual HitWindows CreateHitWindows() => new HitWindows(); + + public IList CreateSlidingSamples() + { + var slidingSamples = new List(); + + var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); + if (normalSample != null) + slidingSamples.Add(normalSample.With("sliderslide")); + + var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); + if (whistleSample != null) + slidingSamples.Add(whistleSample.With("sliderwhistle")); + + return slidingSamples; + } } public static class HitObjectExtensions diff --git a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs index 9af4cf3544..fedf419973 100644 --- a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs +++ b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.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.Bindables; using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Judgements; @@ -24,7 +22,7 @@ namespace osu.Game.Rulesets.Objects /// The result that was judged with. /// This is set by the accompanying , and reused when required for rewinding. /// - internal JudgementResult Result; + internal JudgementResult? Result; private readonly IBindable startTimeBindable = new BindableDouble(); diff --git a/osu.Game/Rulesets/Objects/HitObjectProperty.cs b/osu.Game/Rulesets/Objects/HitObjectProperty.cs new file mode 100644 index 0000000000..f1df83f80c --- /dev/null +++ b/osu.Game/Rulesets/Objects/HitObjectProperty.cs @@ -0,0 +1,52 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Bindables; + +namespace osu.Game.Rulesets.Objects +{ + /// + /// Represents a wrapper containing a lazily-initialised , backed by a temporary field used for storage until initialisation. + /// + public struct HitObjectProperty + { + [CanBeNull] + private Bindable backingBindable; + + /// + /// A temporary field to store the current value to, prior to 's initialisation. + /// + private T backingValue; + + /// + /// The underlying , only initialised on first access. + /// + public Bindable Bindable => backingBindable ??= new Bindable(defaultValue) { Value = backingValue }; + + /// + /// The current value, derived from and delegated to if initialised, or a temporary field otherwise. + /// + public T Value + { + get => backingBindable != null ? backingBindable.Value : backingValue; + set + { + if (backingBindable != null) + backingBindable.Value = value; + else + backingValue = value; + } + } + + private readonly T defaultValue; + + public HitObjectProperty(T value = default) + { + backingValue = defaultValue = value; + backingBindable = null; + } + } +} diff --git a/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs new file mode 100644 index 0000000000..6c39ea44da --- /dev/null +++ b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs @@ -0,0 +1,120 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace osu.Game.Rulesets.Objects.Pooling +{ + /// + /// Manages a mapping between and + /// + internal class HitObjectEntryManager + { + /// + /// All entries, including entries of the nested hit objects. + /// + public IEnumerable AllEntries => entryMap.Values; + + /// + /// Invoked when a new is added to this .. + /// The second parameter of the event is the parent hit object. + /// + public event Action? OnEntryAdded; + + /// + /// Invoked when a is removed from this . + /// The second parameter of the event is the parent hit object. + /// + public event Action? OnEntryRemoved; + + /// + /// Provides the reverse mapping of for each entry. + /// + private readonly Dictionary entryMap = new Dictionary(); + + /// + /// Stores the parent hit object for entries of the nested hit objects. + /// + /// + /// The parent hit object of a pooled hit object may be non-pooled. + /// In that case, no corresponding is stored in this . + /// + private readonly Dictionary parentMap = new Dictionary(); + + /// + /// Stores the list of child entries for each hit object managed by this . + /// + private readonly Dictionary> childrenMap = new Dictionary>(); + + public void Add(HitObjectLifetimeEntry entry, HitObject? parent) + { + HitObject hitObject = entry.HitObject; + + if (entryMap.ContainsKey(hitObject)) + throw new InvalidOperationException($@"The {nameof(HitObjectLifetimeEntry)} is already added to this {nameof(HitObjectEntryManager)}."); + + // Add the entry. + entryMap[hitObject] = entry; + childrenMap[hitObject] = new List(); + + // If the entry has a parent, set it and add the entry to the parent's children. + if (parent != null) + { + parentMap[entry] = parent; + if (childrenMap.TryGetValue(parent, out var parentChildEntries)) + parentChildEntries.Add(entry); + } + + hitObject.DefaultsApplied += onDefaultsApplied; + OnEntryAdded?.Invoke(entry, parent); + } + + public void Remove(HitObjectLifetimeEntry entry) + { + HitObject hitObject = entry.HitObject; + + if (!entryMap.ContainsKey(hitObject)) + throw new InvalidOperationException($@"The {nameof(HitObjectLifetimeEntry)} is not contained in this {nameof(HitObjectEntryManager)}."); + + entryMap.Remove(hitObject); + + // If the entry has a parent, unset it and remove the entry from the parents' children. + if (parentMap.Remove(entry, out var parent) && childrenMap.TryGetValue(parent, out var parentChildEntries)) + parentChildEntries.Remove(entry); + + // Remove all the entries' children. + if (childrenMap.Remove(hitObject, out var childEntries)) + { + foreach (var childEntry in childEntries) + Remove(childEntry); + } + + hitObject.DefaultsApplied -= onDefaultsApplied; + OnEntryRemoved?.Invoke(entry, parent); + } + + public bool TryGet(HitObject hitObject, [MaybeNullWhen(false)] out HitObjectLifetimeEntry entry) + { + return entryMap.TryGetValue(hitObject, out entry); + } + + /// + /// As nested hit objects are recreated, remove entries of the old nested hit objects. + /// + private void onDefaultsApplied(HitObject hitObject) + { + if (!childrenMap.Remove(hitObject, out var childEntries)) + return; + + // Remove all the entries' children. At this point the parents' (this entries') children list has been removed from the map, so this does not cause upwards traversal. + foreach (var entry in childEntries) + Remove(entry); + + // The removed children list needs to be added back to the map for the entry to potentially receive children. + childEntries.Clear(); + childrenMap[hitObject] = childEntries; + } + } +} diff --git a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs index 07a80895e6..d5b4390ce8 100644 --- a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs +++ b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs @@ -132,7 +132,7 @@ namespace osu.Game.Rulesets.Objects.Pooling /// /// Invoked when the entry became dead. /// - protected virtual void RemoveDrawable(TEntry entry, TDrawable drawable) => RemoveInternal(drawable); + protected virtual void RemoveDrawable(TEntry entry, TDrawable drawable) => RemoveInternal(drawable, false); private void entryCrossedBoundary(LifetimeEntry lifetimeEntry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction) { diff --git a/osu.Game/Rulesets/RealmRulesetStore.cs b/osu.Game/Rulesets/RealmRulesetStore.cs index 62a7a13c07..456f6e399b 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,12 +68,25 @@ 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); - var instanceInfo = (Activator.CreateInstance(resolvedType) as Ruleset)?.RulesetInfo + if (resolvedType == null) + { + // ruleset DLL was probably deleted. + r.Available = false; + continue; + } + + var instance = (Activator.CreateInstance(resolvedType) as Ruleset); + var instanceInfo = instance?.RulesetInfo ?? throw new RulesetLoadException(@"Instantiation failure"); + if (!checkRulesetUpToDate(instance)) + { + throw new ArgumentOutOfRangeException(nameof(instance.RulesetAPIVersionSupported), + $"Ruleset API version is too old (was {instance.RulesetAPIVersionSupported}, expected {Ruleset.CURRENT_RULESET_API_VERSION})"); + } + // If a ruleset isn't up-to-date with the API, it could cause a crash at an arbitrary point of execution. // To eagerly handle cases of missing implementations, enumerate all types here and mark as non-available on throw. resolvedType.Assembly.GetTypes(); @@ -83,17 +96,52 @@ 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 bool checkRulesetUpToDate(Ruleset instance) + { + switch (instance.RulesetAPIVersionSupported) + { + // The default `virtual` implementation leaves the version string empty. + // Consider rulesets which haven't override the version as up-to-date for now. + // At some point (once ruleset devs add versioning), we'll probably want to disallow this for deployed builds. + case @"": + // Ruleset is up-to-date, all good. + case Ruleset.CURRENT_RULESET_API_VERSION: + return true; + + default: + return false; + } + } + + 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..a446210c8a 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -1,38 +1,36 @@ // 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.Concurrent; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.IO.Stores; +using osu.Framework.Localisation; +using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Overlays.Settings; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Rulesets.UI; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Skinning; -using osu.Game.Users; -using JetBrains.Annotations; -using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Testing; -using osu.Game.Extensions; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Filter; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; +using osu.Game.Users; namespace osu.Game.Rulesets { @@ -43,6 +41,24 @@ namespace osu.Game.Rulesets private static readonly ConcurrentDictionary mod_reference_cache = new ConcurrentDictionary(); + /// + /// Version history: + /// 2022.205.0 FramedReplayInputHandler.CollectPendingInputs renamed to FramedReplayHandler.CollectReplayInputs. + /// 2022.822.0 All strings return values have been converted to LocalisableString to allow for localisation support. + /// + public const string CURRENT_RULESET_API_VERSION = "2022.822.0"; + + /// + /// Define the ruleset API version supported by this ruleset. + /// Ruleset implementations should be updated to support the latest version to ensure they can still be loaded. + /// + /// + /// Generally, all ruleset implementations should point this directly to . + /// This will ensure that each time you compile a new release, it will pull in the most recent version. + /// See https://github.com/ppy/osu/wiki/Breaking-Changes for full details on required ongoing changes. + /// + public virtual string RulesetAPIVersionSupported => string.Empty; + /// /// A queryable source containing all available mods. /// Call for consumption purposes. @@ -81,7 +97,7 @@ namespace osu.Game.Rulesets /// Returns a fresh instance of the mod matching the specified acronym. /// /// The acronym to query for . - public Mod CreateModFromAcronym(string acronym) + public Mod? CreateModFromAcronym(string acronym) { return AllMods.FirstOrDefault(m => m.Acronym == acronym)?.CreateInstance(); } @@ -89,7 +105,7 @@ namespace osu.Game.Rulesets /// /// Returns a fresh instance of the mod matching the specified type. /// - public T CreateMod() + public T? CreateMod() where T : Mod { return AllMods.FirstOrDefault(m => m is T)?.CreateInstance() as T; @@ -103,7 +119,6 @@ namespace osu.Game.Rulesets /// then the proper behaviour is to return an empty enumerable. /// mods should not be present in the returned enumerable. /// - [ItemNotNull] public abstract IEnumerable GetModsFor(ModType type); /// @@ -183,10 +198,15 @@ namespace osu.Game.Rulesets return value; } - [CanBeNull] - public ModAutoplay GetAutoplayMod() => CreateMod(); + public ModAutoplay? GetAutoplayMod() => CreateMod(); - public virtual ISkin CreateLegacySkinProvider([NotNull] ISkin skin, IBeatmap beatmap) => null; + /// + /// Create a transformer which adds lookups specific to a ruleset to skin sources. + /// + /// The source skin. + /// The current beatmap. + /// A skin with a transformer applied, or null if no transformation is provided by this ruleset. + public virtual ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) => null; protected Ruleset() { @@ -206,7 +226,7 @@ namespace osu.Game.Rulesets /// The beatmap to create the hit renderer for. /// The s to apply. /// Unable to successfully load the beatmap to be usable with this ruleset. - public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null); + public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null); /// /// Creates a for this . @@ -232,7 +252,7 @@ namespace osu.Game.Rulesets /// /// The to be processed. /// The . - public virtual IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => null; + public virtual IBeatmapProcessor? CreateBeatmapProcessor(IBeatmap beatmap) => null; public abstract DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap); @@ -240,12 +260,11 @@ namespace osu.Game.Rulesets /// Optionally creates a to generate performance data from the provided score. /// /// A performance calculator instance for the provided score. - [CanBeNull] - public virtual PerformanceCalculator CreatePerformanceCalculator() => null; + public virtual PerformanceCalculator? CreatePerformanceCalculator() => null; - public virtual HitObjectComposer CreateHitObjectComposer() => null; + public virtual HitObjectComposer? CreateHitObjectComposer() => null; - public virtual IBeatmapVerifier CreateBeatmapVerifier() => null; + public virtual IBeatmapVerifier? CreateBeatmapVerifier() => null; public virtual Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.QuestionCircle }; @@ -253,13 +272,13 @@ namespace osu.Game.Rulesets public abstract string Description { get; } - public virtual RulesetSettingsSubsection CreateSettings() => null; + public virtual RulesetSettingsSubsection? CreateSettings() => null; /// /// Creates the for this . /// /// The to store the settings. - public virtual IRulesetConfigManager CreateConfig(SettingsStore settings) => null; + public virtual IRulesetConfigManager? CreateConfig(SettingsStore? settings) => null; /// /// A unique short name to reference this ruleset in online requests. @@ -288,14 +307,14 @@ 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 /// for conversion use. /// /// An empty frame for the current ruleset, or null if unsupported. - public virtual IConvertibleReplayFrame CreateConvertibleReplayFrame() => null; + public virtual IConvertibleReplayFrame? CreateConvertibleReplayFrame() => null; /// /// Creates the statistics for a to be displayed in the results screen. @@ -303,7 +322,6 @@ namespace osu.Game.Rulesets /// The to create the statistics for. The score is guaranteed to have populated. /// The , converted for this with all relevant s applied. /// The s to display. Each may contain 0 or more . - [NotNull] public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); /// @@ -313,7 +331,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,18 +369,16 @@ 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. /// - [CanBeNull] - public virtual IRulesetFilterCriteria CreateRulesetFilterCriteria() => null; + public virtual IRulesetFilterCriteria? CreateRulesetFilterCriteria() => null; /// /// Can be overridden to add a ruleset-specific section to the editor beatmap setup screen. /// - [CanBeNull] - public virtual RulesetSetupSection CreateEditorSetupSection() => null; + public virtual RulesetSetupSection? CreateEditorSetupSection() => null; } } diff --git a/osu.Game/Rulesets/RulesetConfigCache.cs b/osu.Game/Rulesets/RulesetConfigCache.cs index 017214df61..ab44e86048 100644 --- a/osu.Game/Rulesets/RulesetConfigCache.cs +++ b/osu.Game/Rulesets/RulesetConfigCache.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; using System.Collections.Generic; using osu.Framework.Graphics; @@ -18,7 +16,7 @@ namespace osu.Game.Rulesets private readonly RealmAccess realm; private readonly RulesetStore rulesets; - private readonly Dictionary configCache = new Dictionary(); + private readonly Dictionary configCache = new Dictionary(); public RulesetConfigCache(RealmAccess realm, RulesetStore rulesets) { @@ -42,7 +40,7 @@ namespace osu.Game.Rulesets } } - public IRulesetConfigManager GetConfigFor(Ruleset ruleset) + public IRulesetConfigManager? GetConfigFor(Ruleset ruleset) { if (!IsLoaded) throw new InvalidOperationException($@"Cannot retrieve {nameof(IRulesetConfigManager)} before {nameof(RulesetConfigCache)} has loaded"); diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index b5e5fa1561..91954320a4 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -4,6 +4,7 @@ using System; using JetBrains.Annotations; using osu.Framework.Testing; +using osu.Game.Rulesets.Difficulty; using Realms; namespace osu.Game.Rulesets @@ -22,6 +23,11 @@ namespace osu.Game.Rulesets public string InstantiationInfo { get; set; } = string.Empty; + /// + /// Stores the last applied + /// + public int LastAppliedDifficultyVersion { get; set; } + public RulesetInfo(string shortName, string name, string instantiationInfo, int onlineID) { ShortName = shortName; @@ -86,7 +92,8 @@ namespace osu.Game.Rulesets Name = Name, ShortName = ShortName, InstantiationInfo = InstantiationInfo, - Available = Available + Available = Available, + LastAppliedDifficultyVersion = LastAppliedDifficultyVersion, }; public Ruleset CreateInstance() 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..96e13e5861 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -1,13 +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 System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; namespace osu.Game.Rulesets.Scoring @@ -119,10 +119,24 @@ 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 { + private static readonly IList order = EnumExtensions.GetValuesInOrder().ToList(); + /// /// Whether a increases the combo. /// @@ -150,6 +164,7 @@ namespace osu.Game.Rulesets.Scoring case HitResult.Perfect: case HitResult.LargeTickHit: case HitResult.LargeTickMiss: + case HitResult.LegacyComboIncrease: return true; default: @@ -161,13 +176,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 +252,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. @@ -250,5 +284,13 @@ namespace osu.Game.Rulesets.Scoring Debug.Assert(minResult <= maxResult); return result > minResult && result < maxResult; } + + /// + /// Ordered index of a . Used for consistent order when displaying hit results to the user. + /// + /// The to get the index of. + /// The index of . + public static int GetIndexForOrderedDisplay(this HitResult result) => order.IndexOf(result); } +#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 56bbe031e6..7456ce06bd 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -16,6 +16,8 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Scoring; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Rulesets.Scoring { @@ -119,13 +121,15 @@ namespace osu.Game.Rulesets.Scoring /// /// The maximum of a basic (non-tick and non-bonus) hitobject. - /// Only populated via or . + /// Only populated via or . /// private HitResult? maxBasicResult; private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); + private readonly Dictionary maximumResultCounts = new Dictionary(); + private readonly List hitEvents = new List(); private HitObject? lastHitObject; @@ -268,7 +272,24 @@ namespace osu.Game.Rulesets.Scoring } /// - /// Computes the total score of a given finalised . This should be used when a score is known to be complete. + /// Computes the accuracy of a given . + /// + /// The to compute the total score of. + /// The score's accuracy. + [Pure] + public double ComputeAccuracy(ScoreInfo scoreInfo) + { + if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) + throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); + + // We only extract scoring values from the score's statistics. This is because accuracy is always relative to the point of pass or fail rather than relative to the whole beatmap. + extractScoringValues(scoreInfo.Statistics, out var current, out var maximum); + + return maximum.BaseScore > 0 ? current.BaseScore / maximum.BaseScore : 1; + } + + /// + /// Computes the total score of a given . /// /// /// Does not require to have been called before use. @@ -277,7 +298,7 @@ namespace osu.Game.Rulesets.Scoring /// The to compute the total score of. /// The total score in the given . [Pure] - public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo) + public double ComputeScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); @@ -287,60 +308,6 @@ namespace osu.Game.Rulesets.Scoring return ComputeScore(mode, current, maximum); } - /// - /// Computes the total score of a partially-completed . This should be used when it is unknown whether a score is complete. - /// - /// - /// Requires to have been called before use. - /// - /// The to represent the score as. - /// The to compute the total score of. - /// The total score in the given . - [Pure] - public double ComputePartialScore(ScoringMode mode, ScoreInfo scoreInfo) - { - if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - if (!beatmapApplied) - throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}."); - - ExtractScoringValues(scoreInfo, out var current, out _); - - return ComputeScore(mode, current, MaximumScoringValues); - } - - /// - /// Computes the total score of a given with a given custom max achievable combo. - /// - /// - /// This is useful for processing legacy scores in which the maximum achievable combo can be more accurately determined via external means (e.g. database values or difficulty calculation). - ///

Does not require to have been called before use.

- ///
- /// The to represent the score as. - /// The to compute the total score of. - /// The maximum achievable combo for the provided beatmap. - /// The total score in the given . - [Pure] - public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo) - { - if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - double accuracyRatio = scoreInfo.Accuracy; - double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; - - ExtractScoringValues(scoreInfo, out var current, out var maximum); - - // For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score. - // To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score. - // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3 && maximum.BaseScore > 0) - accuracyRatio = current.BaseScore / maximum.BaseScore; - - return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); - } - /// /// Computes the total score from scoring values. /// @@ -400,8 +367,6 @@ namespace osu.Game.Rulesets.Scoring return ScoreRank.D; } - public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result); - /// /// Resets this ScoreProcessor to a default state. /// @@ -410,19 +375,26 @@ namespace osu.Game.Rulesets.Scoring { base.Reset(storeResults); - scoreResultCounts.Clear(); hitEvents.Clear(); lastHitObject = null; if (storeResults) + { maximumScoringValues = currentScoringValues; + maximumResultCounts.Clear(); + maximumResultCounts.AddRange(scoreResultCounts); + } + + scoreResultCounts.Clear(); + currentScoringValues = default; currentMaximumScoringValues = default; TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; + Rank.Disabled = false; Rank.Value = ScoreRank.X; HighestCombo.Value = 0; } @@ -439,10 +411,27 @@ 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)); + score.TotalScore = (long)Math.Round(ComputeScore(ScoringMode.Standardised, score)); + } + + /// + /// Populates a failed score, marking it with the rank. + /// + public void FailScore(ScoreInfo score) + { + if (Rank.Value == ScoreRank.F) + return; + + score.Passed = false; + Rank.Value = ScoreRank.F; + + PopulateScore(score); } public override void ResetFromReplayFrame(ReplayFrame frame) @@ -494,6 +483,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); } /// @@ -549,7 +541,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). @@ -598,7 +591,10 @@ namespace osu.Game.Rulesets.Scoring public enum ScoringMode { + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.StandardisedScoreDisplay))] Standardised, + + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } } diff --git a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs index 1e80bd165b..279de2f940 100644 --- a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs +++ b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs @@ -11,12 +11,12 @@ namespace osu.Game.Rulesets.Timing /// /// A control point which adds an aggregated multiplier based on the provided 's BeatLength and 's SpeedMultiplier. /// - public class MultiplierControlPoint : IComparable + public class MultiplierControlPoint : IComparable, IControlPoint { /// /// The time in milliseconds at which this starts. /// - public double StartTime; + public double Time { get; set; } /// /// The aggregate multiplier which this provides. @@ -54,13 +54,13 @@ namespace osu.Game.Rulesets.Timing /// /// Creates a . /// - /// The start time of this . - public MultiplierControlPoint(double startTime) + /// The start time of this . + public MultiplierControlPoint(double time) { - StartTime = startTime; + Time = time; } // ReSharper disable once ImpureMethodCallOnReadonlyValueField - public int CompareTo(MultiplierControlPoint other) => StartTime.CompareTo(other?.StartTime); + public int CompareTo(MultiplierControlPoint other) => Time.CompareTo(other?.Time); } } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 95cb02c477..dd3a950264 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -30,6 +30,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; using osuTK; namespace osu.Game.Rulesets.UI @@ -38,7 +39,7 @@ namespace osu.Game.Rulesets.UI /// Displays an interactive ruleset gameplay instance. /// /// The type of HitObject contained by this DrawableRuleset. - public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachKeyCounter + public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachHUDPieces where TObject : HitObject { public override event Action NewResult; @@ -67,7 +68,7 @@ namespace osu.Game.Rulesets.UI public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; - public override IFrameStableClock FrameStableClock => frameStabilityContainer.FrameStableClock; + public override IFrameStableClock FrameStableClock => frameStabilityContainer; private bool frameStablePlayback = true; @@ -338,7 +339,10 @@ namespace osu.Game.Rulesets.UI public abstract DrawableHitObject CreateDrawableRepresentation(TObject h); public void Attach(KeyCounterDisplay keyCounter) => - (KeyBindingInputManager as ICanAttachKeyCounter)?.Attach(keyCounter); + (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(keyCounter); + + public void Attach(ClicksPerSecondCalculator calculator) => + (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(calculator); /// /// Creates a key conversion input manager. An exception will be thrown if a valid is not returned. @@ -495,6 +499,7 @@ namespace osu.Game.Rulesets.UI /// /// The cursor being displayed by the . May be null if no cursor is provided. /// + [CanBeNull] public abstract GameplayCursorContainer Cursor { get; } /// diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 6a7369a150..ad52b4affc 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.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; using System.Collections.Generic; using System.IO; @@ -12,7 +10,8 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; @@ -44,27 +43,26 @@ namespace osu.Game.Rulesets.UI public ShaderManager ShaderManager { get; } /// - /// The ruleset config manager. + /// The ruleset config manager. May be null if ruleset does not expose a configuration manager. /// - public IRulesetConfigManager RulesetConfigManager { get; private set; } + public IRulesetConfigManager? RulesetConfigManager { get; } public DrawableRulesetDependencies(Ruleset ruleset, IReadOnlyDependencyContainer parent) : base(parent) { var resources = ruleset.CreateResourceStore(); - if (resources != null) - { - TextureStore = new TextureStore(parent.Get().CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); - CacheAs(TextureStore = new FallbackTextureStore(TextureStore, parent.Get())); + var host = parent.Get(); - SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); - SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; - CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); + TextureStore = new TextureStore(host.Renderer, parent.Get().CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + CacheAs(TextureStore = new FallbackTextureStore(host.Renderer, TextureStore, parent.Get())); - ShaderManager = new ShaderManager(new NamespacedResourceStore(resources, @"Shaders")); - CacheAs(ShaderManager = new FallbackShaderManager(ShaderManager, parent.Get())); - } + SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); + SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); + + ShaderManager = new ShaderManager(host.Renderer, new NamespacedResourceStore(resources, @"Shaders")); + CacheAs(ShaderManager = new FallbackShaderManager(host.Renderer, ShaderManager, parent.Get())); RulesetConfigManager = parent.Get().GetConfigFor(ruleset); if (RulesetConfigManager != null) @@ -94,10 +92,9 @@ namespace osu.Game.Rulesets.UI isDisposed = true; - SampleStore?.Dispose(); - TextureStore?.Dispose(); - ShaderManager?.Dispose(); - RulesetConfigManager = null; + if (ShaderManager.IsNotNull()) SampleStore.Dispose(); + if (TextureStore.IsNotNull()) TextureStore.Dispose(); + if (ShaderManager.IsNotNull()) ShaderManager.Dispose(); } #endregion @@ -158,7 +155,7 @@ namespace osu.Game.Rulesets.UI public void Dispose() { - primary?.Dispose(); + if (primary.IsNotNull()) primary.Dispose(); } } @@ -170,7 +167,8 @@ namespace osu.Game.Rulesets.UI private readonly TextureStore primary; private readonly TextureStore fallback; - public FallbackTextureStore(TextureStore primary, TextureStore fallback) + public FallbackTextureStore(IRenderer renderer, TextureStore primary, TextureStore fallback) + : base(renderer) { this.primary = primary; this.fallback = fallback; @@ -182,7 +180,7 @@ namespace osu.Game.Rulesets.UI protected override void Dispose(bool disposing) { base.Dispose(disposing); - primary?.Dispose(); + if (primary.IsNotNull()) primary.Dispose(); } } @@ -191,19 +189,19 @@ namespace osu.Game.Rulesets.UI private readonly ShaderManager primary; private readonly ShaderManager fallback; - public FallbackShaderManager(ShaderManager primary, ShaderManager fallback) - : base(new ResourceStore()) + public FallbackShaderManager(IRenderer renderer, ShaderManager primary, ShaderManager fallback) + : base(renderer, new ResourceStore()) { this.primary = primary; this.fallback = fallback; } - public override byte[] LoadRaw(string name) => primary.LoadRaw(name) ?? fallback.LoadRaw(name); + public override byte[]? LoadRaw(string name) => primary.LoadRaw(name) ?? fallback.LoadRaw(name); protected override void Dispose(bool disposing) { base.Dispose(disposing); - primary?.Dispose(); + if (primary.IsNotNull()) primary.Dispose(); } } } diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index dcd7141419..3b35fba122 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -1,12 +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 System.Collections.Generic; -using System.Linq; +using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,9 +18,11 @@ namespace osu.Game.Rulesets.UI /// A container which consumes a parent gameplay clock and standardises frame counts for children. /// Will ensure a minimum of 50 frames per clock second is maintained, regardless of any system lag or seeks. /// - public class FrameStabilityContainer : Container, IHasReplayHandler + [Cached(typeof(IGameplayClock))] + [Cached(typeof(IFrameStableClock))] + public sealed class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock { - private readonly double gameplayStartTime; + public ReplayInputHandler? ReplayInputHandler { get; set; } /// /// The number of frames (per parent frame) which can be run in an attempt to catch-up to real-time. @@ -32,28 +32,35 @@ namespace osu.Game.Rulesets.UI /// /// Whether to enable frame-stable playback. /// - internal bool FrameStablePlayback = true; + internal bool FrameStablePlayback { get; set; } = true; - public IFrameStableClock FrameStableClock => frameStableClock; + protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid; - [Cached(typeof(GameplayClock))] - private readonly FrameStabilityClock frameStableClock; + private readonly Bindable isCatchingUp = new Bindable(); - public FrameStabilityContainer(double gameplayStartTime = double.MinValue) - { - RelativeSizeAxes = Axes.Both; + private readonly Bindable waitingOnFrames = new Bindable(); - frameStableClock = new FrameStabilityClock(framedClock = new FramedClock(manualClock = new ManualClock())); + private readonly double gameplayStartTime; - this.gameplayStartTime = gameplayStartTime; - } + private IGameplayClock? parentGameplayClock; + /// + /// A clock which is used as reference for time, rate and running state. + /// + private IClock referenceClock = null!; + + /// + /// A local manual clock which tracks the reference clock. + /// Values are transferred from each update call. + /// private readonly ManualClock manualClock; + /// + /// The main framed clock which has stability applied to it. + /// This gets exposed to children as an . + /// private readonly FramedClock framedClock; - private IFrameBasedClock parentGameplayClock; - /// /// The current direction of playback to be exposed to frame stable children. /// @@ -62,32 +69,34 @@ namespace osu.Game.Rulesets.UI /// private int direction = 1; - [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock) - { - if (clock != null) - { - parentGameplayClock = frameStableClock.ParentGameplayClock = clock; - frameStableClock.IsPaused.BindTo(clock.IsPaused); - } - } - - protected override void LoadComplete() - { - base.LoadComplete(); - setClock(); - } - private PlaybackState state; - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid; - private bool hasReplayAttached => ReplayInputHandler != null; - private const double sixty_frame_time = 1000.0 / 60; - private bool firstConsumption = true; + public FrameStabilityContainer(double gameplayStartTime = double.MinValue) + { + RelativeSizeAxes = Axes.Both; + + framedClock = new FramedClock(manualClock = new ManualClock()); + + this.gameplayStartTime = gameplayStartTime; + } + + [BackgroundDependencyLoader(true)] + private void load(IGameplayClock? gameplayClock) + { + if (gameplayClock != null) + { + parentGameplayClock = gameplayClock; + IsPaused.BindTo(parentGameplayClock.IsPaused); + } + + referenceClock = gameplayClock ?? Clock; + Clock = this; + } + public override bool UpdateSubTree() { int loops = MaxCatchUpFrames; @@ -110,12 +119,12 @@ namespace osu.Game.Rulesets.UI private void updateClock() { - if (frameStableClock.WaitingOnFrames.Value) + if (waitingOnFrames.Value) { // if waiting on frames, run one update loop to determine if frames have arrived. state = PlaybackState.Valid; } - else if (frameStableClock.IsPaused.Value) + else if (IsPaused.Value) { // time should not advance while paused, nor should anything run. state = PlaybackState.NotValid; @@ -126,10 +135,7 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.Valid; } - if (parentGameplayClock == null) - setClock(); // LoadComplete may not be run yet, but we still want the clock. - - double proposedTime = parentGameplayClock.CurrentTime; + double proposedTime = referenceClock.CurrentTime; if (FrameStablePlayback) // if we require frame stability, the proposed time will be adjusted to move at most one known @@ -149,14 +155,14 @@ namespace osu.Game.Rulesets.UI if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime) direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; - double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); + double timeBehind = Math.Abs(proposedTime - referenceClock.CurrentTime); - frameStableClock.IsCatchingUp.Value = timeBehind > 200; - frameStableClock.WaitingOnFrames.Value = state == PlaybackState.NotValid; + isCatchingUp.Value = timeBehind > 200; + waitingOnFrames.Value = state == PlaybackState.NotValid; manualClock.CurrentTime = proposedTime; - manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction; - manualClock.IsRunning = parentGameplayClock.IsRunning; + manualClock.Rate = Math.Abs(referenceClock.Rate) * direction; + manualClock.IsRunning = referenceClock.IsRunning; // determine whether catch-up is required. if (state == PlaybackState.Valid && timeBehind > 0) @@ -174,6 +180,8 @@ namespace osu.Game.Rulesets.UI /// Whether playback is still valid. private bool updateReplay(ref double proposedTime) { + Debug.Assert(ReplayInputHandler != null); + double? newTime; if (FrameStablePlayback) @@ -210,6 +218,8 @@ namespace osu.Game.Rulesets.UI /// The time which is to be displayed. private void applyFrameStability(ref double proposedTime) { + const double sixty_frame_time = 1000.0 / 60; + if (firstConsumption) { // On the first update, frame-stability seeking would result in unexpected/unwanted behaviour. @@ -233,20 +243,38 @@ namespace osu.Game.Rulesets.UI } } - private void setClock() - { - if (parentGameplayClock == null) - { - // in case a parent gameplay clock isn't available, just use the parent clock. - parentGameplayClock ??= Clock; - } - else - { - Clock = frameStableClock; - } - } + #region Delegation of IGameplayClock - public ReplayInputHandler ReplayInputHandler { get; set; } + public IBindable IsPaused { get; } = new BindableBool(); + + public double CurrentTime => framedClock.CurrentTime; + + public double Rate => framedClock.Rate; + + public bool IsRunning => framedClock.IsRunning; + + public void ProcessFrame() { } + + public double ElapsedFrameTime => framedClock.ElapsedFrameTime; + + public double FramesPerSecond => framedClock.FramesPerSecond; + + public FrameTimeInfo TimeInfo => framedClock.TimeInfo; + + public double StartTime => parentGameplayClock?.StartTime ?? 0; + + private readonly AudioAdjustments gameplayAdjustments = new AudioAdjustments(); + + public IAdjustableAudioComponent AdjustmentsFromMods => parentGameplayClock?.AdjustmentsFromMods ?? gameplayAdjustments; + + #endregion + + #region Delegation of IFrameStableClock + + IBindable IFrameStableClock.IsCatchingUp => isCatchingUp; + IBindable IFrameStableClock.WaitingOnFrames => waitingOnFrames; + + #endregion private enum PlaybackState { @@ -266,25 +294,5 @@ namespace osu.Game.Rulesets.UI /// Valid } - - private class FrameStabilityClock : GameplayClock, IFrameStableClock - { - public GameplayClock ParentGameplayClock; - - public readonly Bindable IsCatchingUp = new Bindable(); - - public readonly Bindable WaitingOnFrames = new Bindable(); - - public override IEnumerable> NonGameplayAdjustments => ParentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty>(); - - public FrameStabilityClock(FramedClock underlyingClock) - : base(underlyingClock) - { - } - - IBindable IFrameStableClock.IsCatchingUp => IsCatchingUp; - - IBindable IFrameStableClock.WaitingOnFrames => WaitingOnFrames; - } } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 589b585643..bbced9e58c 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -124,7 +124,7 @@ namespace osu.Game.Rulesets.UI unbindStartTime(drawable); - RemoveInternal(drawable); + RemoveInternal(drawable, false); } #endregion diff --git a/osu.Game/Rulesets/UI/IFrameStableClock.cs b/osu.Game/Rulesets/UI/IFrameStableClock.cs index 132605adaf..4e50d059e9 100644 --- a/osu.Game/Rulesets/UI/IFrameStableClock.cs +++ b/osu.Game/Rulesets/UI/IFrameStableClock.cs @@ -1,14 +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 osu.Framework.Bindables; -using osu.Framework.Timing; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.UI { - public interface IFrameStableClock : IFrameBasedClock + public interface IFrameStableClock : IGameplayClock { IBindable IsCatchingUp { get; } diff --git a/osu.Game/Rulesets/UI/JudgementContainer.cs b/osu.Game/Rulesets/UI/JudgementContainer.cs index 4336977aa8..471a62cab3 100644 --- a/osu.Game/Rulesets/UI/JudgementContainer.cs +++ b/osu.Game/Rulesets/UI/JudgementContainer.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.UI // remove any existing judgements for the judged object. // this can be the case when rewinding. - RemoveAll(c => c.JudgedObject == judgement.JudgedObject); + RemoveAll(c => c.JudgedObject == judgement.JudgedObject, false); base.Add(judgement); } diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 0f21a497b0..b1f355a789 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Localisation; namespace osu.Game.Rulesets.UI @@ -53,7 +54,6 @@ namespace osu.Game.Rulesets.UI private OsuColour colours { get; set; } private Color4 backgroundColour; - private Color4 highlightedColour; /// /// Construct a new instance. @@ -123,47 +123,13 @@ namespace osu.Game.Rulesets.UI modAcronym.FadeOut(); } - switch (value.Type) - { - default: - case ModType.DifficultyIncrease: - backgroundColour = colours.Yellow; - highlightedColour = colours.YellowLight; - break; - - case ModType.DifficultyReduction: - backgroundColour = colours.Green; - highlightedColour = colours.GreenLight; - break; - - case ModType.Automation: - backgroundColour = colours.Blue; - highlightedColour = colours.BlueLight; - break; - - case ModType.Conversion: - backgroundColour = colours.Purple; - highlightedColour = colours.PurpleLight; - break; - - case ModType.Fun: - backgroundColour = colours.Pink; - highlightedColour = colours.PinkLight; - break; - - case ModType.System: - backgroundColour = colours.Gray6; - highlightedColour = colours.Gray7; - modIcon.Colour = colours.Yellow; - break; - } - + backgroundColour = colours.ForModType(value.Type); updateColour(); } private void updateColour() { - background.Colour = Selected.Value ? highlightedColour : backgroundColour; + background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; } } } diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 60d1555052..e59e45722a 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; using osuTK; +using osu.Game.Rulesets.Objects.Pooling; namespace osu.Game.Rulesets.UI { @@ -94,6 +95,8 @@ namespace osu.Game.Rulesets.UI [Resolved(CanBeNull = true)] private IReadOnlyList mods { get; set; } + private readonly HitObjectEntryManager entryManager = new HitObjectEntryManager(); + /// /// Creates a new . /// @@ -108,6 +111,9 @@ namespace osu.Game.Rulesets.UI h.HitObjectUsageBegan += o => HitObjectUsageBegan?.Invoke(o); h.HitObjectUsageFinished += o => HitObjectUsageFinished?.Invoke(o); })); + + entryManager.OnEntryAdded += onEntryAdded; + entryManager.OnEntryRemoved += onEntryRemoved; } [BackgroundDependencyLoader] @@ -171,6 +177,7 @@ namespace osu.Game.Rulesets.UI /// The added . protected virtual void OnHitObjectAdded(HitObject hitObject) { + preloadSamples(hitObject); } /// @@ -195,16 +202,14 @@ namespace osu.Game.Rulesets.UI /// /// The cursor currently being used by this . May be null if no cursor is provided. /// + [CanBeNull] public GameplayCursorContainer Cursor { get; private set; } /// /// Provide a cursor which is to be used for gameplay. /// - /// - /// The default provided cursor is invisible when inside the bounds of the . - /// /// The cursor, or null to show the menu cursor. - protected virtual GameplayCursorContainer CreateCursor() => new InvisibleCursorContainer(); + protected virtual GameplayCursorContainer CreateCursor() => null; /// /// Registers a as a nested . @@ -264,12 +269,7 @@ namespace osu.Game.Rulesets.UI public virtual void Add(HitObject hitObject) { var entry = CreateLifetimeEntry(hitObject); - lifetimeEntryMap[entry.HitObject] = entry; - - preloadSamples(hitObject); - - HitObjectContainer.Add(entry); - OnHitObjectAdded(entry.HitObject); + entryManager.Add(entry, null); } private void preloadSamples(HitObject hitObject) @@ -292,16 +292,31 @@ namespace osu.Game.Rulesets.UI /// Whether the was successfully removed. public virtual bool Remove(HitObject hitObject) { - if (lifetimeEntryMap.Remove(hitObject, out var entry)) + if (entryManager.TryGet(hitObject, out var entry)) { - HitObjectContainer.Remove(entry); - OnHitObjectRemoved(hitObject); + entryManager.Remove(entry); return true; } return nestedPlayfields.Any(p => p.Remove(hitObject)); } + private void onEntryAdded(HitObjectLifetimeEntry entry, [CanBeNull] HitObject parentHitObject) + { + if (parentHitObject != null) return; + + HitObjectContainer.Add(entry); + OnHitObjectAdded(entry.HitObject); + } + + private void onEntryRemoved(HitObjectLifetimeEntry entry, [CanBeNull] HitObject parentHitObject) + { + if (parentHitObject != null) return; + + HitObjectContainer.Remove(entry); + OnHitObjectRemoved(entry.HitObject); + } + /// /// Creates the for a given . /// @@ -366,8 +381,11 @@ namespace osu.Game.Rulesets.UI } } - if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry)) - lifetimeEntryMap[hitObject] = entry = CreateLifetimeEntry(hitObject); + if (!entryManager.TryGet(hitObject, out var entry)) + { + entry = CreateLifetimeEntry(hitObject); + entryManager.Add(entry, parent?.HitObject); + } dho.ParentHitObject = parent; dho.Apply(entry); @@ -442,8 +460,6 @@ namespace osu.Game.Rulesets.UI /// internal event Action HitObjectUsageFinished; - private readonly Dictionary lifetimeEntryMap = new Dictionary(); - /// /// Sets whether to keep a given always alive within this or any nested . /// @@ -451,7 +467,7 @@ namespace osu.Game.Rulesets.UI /// Whether to keep always alive. internal void SetKeepAlive(HitObject hitObject, bool keepAlive) { - if (lifetimeEntryMap.TryGetValue(hitObject, out var entry)) + if (entryManager.TryGet(hitObject, out var entry)) { entry.KeepAlive = keepAlive; return; @@ -466,7 +482,7 @@ namespace osu.Game.Rulesets.UI /// internal void KeepAllAlive() { - foreach (var (_, entry) in lifetimeEntryMap) + foreach (var entry in entryManager.AllEntries) entry.KeepAlive = true; foreach (var p in nestedPlayfields) @@ -504,14 +520,5 @@ namespace osu.Game.Rulesets.UI } #endregion - - public class InvisibleCursorContainer : GameplayCursorContainer - { - protected override Drawable CreateCursor() => new InvisibleCursor(); - - private class InvisibleCursor : Drawable - { - } - } } } diff --git a/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs b/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs index a4a483e930..79f3a2ca84 100644 --- a/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs +++ b/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs @@ -3,12 +3,20 @@ #nullable disable +using osu.Framework.Localisation; +using osu.Game.Localisation; + namespace osu.Game.Rulesets.UI { public enum PlayfieldBorderStyle { + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.BorderNone))] None, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.BorderCorners))] Corners, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.BorderFull))] Full } } diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index b04807e475..79da56fc8a 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -14,7 +14,6 @@ using osu.Framework.Input.Events; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Replays; using osu.Game.Scoring; -using osu.Game.Screens.Play; using osuTK; namespace osu.Game.Rulesets.UI @@ -33,9 +32,6 @@ namespace osu.Game.Rulesets.UI [Resolved] private SpectatorClient spectatorClient { get; set; } - [Resolved] - private GameplayState gameplayState { get; set; } - protected ReplayRecorder(Score target) { this.target = target; @@ -48,15 +44,7 @@ namespace osu.Game.Rulesets.UI protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); - spectatorClient.BeginPlaying(gameplayState, target); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - spectatorClient?.EndPlaying(gameplayState); } protected override void Update() diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 7c37913576..64ac021204 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -20,11 +20,12 @@ using osu.Game.Input.Bindings; using osu.Game.Input.Handlers; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.UI { - public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler, IHasRecordingHandler + public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachHUDPieces, IHasReplayHandler, IHasRecordingHandler where T : struct { public readonly KeyBindingContainer KeyBindingContainer; @@ -168,7 +169,7 @@ namespace osu.Game.Rulesets.UI .Select(action => new KeyCounterAction(action))); } - public class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler + private class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler { public ActionReceptor(KeyCounterDisplay target) : base(target) @@ -186,6 +187,37 @@ namespace osu.Game.Rulesets.UI #endregion + #region Keys per second Counter Attachment + + public void Attach(ClicksPerSecondCalculator calculator) + { + var listener = new ActionListener(calculator); + + KeyBindingContainer.Add(listener); + } + + private class ActionListener : Component, IKeyBindingHandler + { + private readonly ClicksPerSecondCalculator calculator; + + public ActionListener(ClicksPerSecondCalculator calculator) + { + this.calculator = calculator; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + calculator.AddInputTimestamp(); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + + #endregion + protected virtual KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new RulesetKeyBindingContainer(ruleset, variant, unique); @@ -198,9 +230,9 @@ namespace osu.Game.Rulesets.UI { } - protected override void ReloadMappings() + protected override void ReloadMappings(IQueryable realmKeyBindings) { - base.ReloadMappings(); + base.ReloadMappings(realmKeyBindings); KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); } @@ -221,12 +253,13 @@ namespace osu.Game.Rulesets.UI } /// - /// Supports attaching a . + /// Supports attaching various HUD pieces. /// Keys will be populated automatically and a receptor will be injected inside. /// - public interface ICanAttachKeyCounter + public interface ICanAttachHUDPieces { void Attach(KeyCounterDisplay keyCounter); + void Attach(ClicksPerSecondCalculator calculator); } public class RulesetInputManagerInputState : InputState diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs index 0bd8aa64c9..c957a84eb1 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms return -PositionAt(startTime, endTime, timeRange, scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) => (float)((time - currentTime) / timeRange * scrollLength); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs index d2fb9e3531..f78509f919 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs @@ -53,8 +53,9 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The current time. /// The amount of visible time. /// The absolute spatial length through . + /// The time to be used for control point lookups (ie. the parent's start time for nested hit objects). /// The absolute spatial position. - float PositionAt(double time, double currentTime, double timeRange, float scrollLength); + float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null); /// /// Computes the time which brings a point to a provided spatial position given the current time. @@ -63,7 +64,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The current time. /// The amount of visible time. /// The absolute spatial length through . - /// The time at which == . + /// The time at which == . double TimeAt(float position, double currentTime, double timeRange, float scrollLength); /// diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index d41117bce8..54079c7895 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs @@ -4,22 +4,20 @@ #nullable disable using System; +using System.Linq; using osu.Framework.Lists; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Timing; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class OverlappingScrollAlgorithm : IScrollAlgorithm { - private readonly MultiplierControlPoint searchPoint; - private readonly SortedList controlPoints; public OverlappingScrollAlgorithm(SortedList controlPoints) { this.controlPoints = controlPoints; - - searchPoint = new MultiplierControlPoint(); } public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) @@ -37,8 +35,8 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms return -PositionAt(startTime, endTime, timeRange, scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => (float)((time - currentTime) / timeRange * controlPointAt(time).Multiplier * scrollLength); + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) + => (float)((time - currentTime) / timeRange * controlPointAt(originTime ?? time).Multiplier * scrollLength); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { @@ -52,7 +50,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms for (; i < controlPoints.Count; i++) { float lastPos = pos; - pos = PositionAt(controlPoints[i].StartTime, currentTime, timeRange, scrollLength); + pos = PositionAt(controlPoints[i].Time, currentTime, timeRange, scrollLength); if (pos > position) { @@ -64,7 +62,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms i = Math.Clamp(i, 0, controlPoints.Count - 1); - return controlPoints[i].StartTime + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; + return controlPoints[i].Time + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; } public void Reset() @@ -78,19 +76,11 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The . private MultiplierControlPoint controlPointAt(double time) { - if (controlPoints.Count == 0) - return new MultiplierControlPoint(double.NegativeInfinity); - - if (time < controlPoints[0].StartTime) - return controlPoints[0]; - - searchPoint.StartTime = time; - int index = controlPoints.BinarySearch(searchPoint); - - if (index < 0) - index = ~index - 1; - - return controlPoints[index]; + return ControlPointInfo.BinarySearch(controlPoints, time) + // The standard binary search will fail if there's no control points, or if the time is before the first. + // For this method, we want to use the first control point in the latter case. + ?? controlPoints.FirstOrDefault() + ?? new MultiplierControlPoint(double.NegativeInfinity); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs index bfddc22573..774beb20c7 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms return (float)(objectLength * scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) { double timelineLength = relativePositionAt(time, timeRange) - relativePositionAt(currentTime, timeRange); return (float)(timelineLength * scrollLength); @@ -121,7 +121,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms if (controlPoints.Count == 0) return; - positionMappings.Add(new PositionMapping(controlPoints[0].StartTime, controlPoints[0])); + positionMappings.Add(new PositionMapping(controlPoints[0].Time, controlPoints[0])); for (int i = 0; i < controlPoints.Count - 1; i++) { @@ -129,9 +129,9 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms var next = controlPoints[i + 1]; // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier - float length = (float)((next.StartTime - current.StartTime) / timeRange * current.Multiplier); + float length = (float)((next.Time - current.Time) / timeRange * current.Multiplier); - positionMappings.Add(new PositionMapping(next.StartTime, next, positionMappings[^1].Position + length)); + positionMappings.Add(new PositionMapping(next.Time, next, positionMappings[^1].Position + length)); } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index d8a8a6ccd8..68469d083c 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -60,7 +60,6 @@ namespace osu.Game.Rulesets.UI.Scrolling /// protected readonly BindableDouble TimeRange = new BindableDouble(time_span_default) { - Default = time_span_default, MinValue = time_span_min, MaxValue = time_span_max }; @@ -159,9 +158,9 @@ namespace osu.Game.Rulesets.UI.Scrolling // Trim unwanted sequences of timing changes timingChanges = timingChanges // Collapse sections after the last hit object - .Where(s => s.StartTime <= lastObjectTime) + .Where(s => s.Time <= lastObjectTime) // Collapse sections with the same start time - .GroupBy(s => s.StartTime).Select(g => g.Last()).OrderBy(s => s.StartTime); + .GroupBy(s => s.Time).Select(g => g.Last()).OrderBy(s => s.Time); ControlPoints.AddRange(timingChanges); diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 0ed3ca1e63..424fc7c44c 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -3,11 +3,12 @@ #nullable disable +using System; using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; using osu.Framework.Layout; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -92,9 +93,9 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// Given a time, return the position along the scrolling axis within this at time . /// - public float PositionAtTime(double time, double currentTime) + public float PositionAtTime(double time, double currentTime, double? originTime = null) { - float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength); + float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength, originTime); return axisInverted ? -scrollPosition : scrollPosition; } @@ -126,6 +127,16 @@ namespace osu.Game.Rulesets.UI.Scrolling private float scrollLength => scrollingAxis == Direction.Horizontal ? DrawWidth : DrawHeight; + public override void Add(HitObjectLifetimeEntry entry) + { + // Scroll info is not available until loaded. + // The lifetime of all entries will be updated in the first Update. + if (IsLoaded) + setComputedLifetimeStart(entry); + + base.Add(entry); + } + protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) { base.AddDrawable(entry, drawable); @@ -144,7 +155,6 @@ namespace osu.Game.Rulesets.UI.Scrolling private void invalidateHitObject(DrawableHitObject hitObject) { - hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); layoutComputed.Remove(hitObject); } @@ -156,10 +166,8 @@ namespace osu.Game.Rulesets.UI.Scrolling layoutComputed.Clear(); - // Reset lifetime to the conservative estimation. - // If a drawable becomes alive by this lifetime, its lifetime will be updated to a more precise lifetime in the next update. foreach (var entry in Entries) - entry.SetInitialLifetime(); + setComputedLifetimeStart(entry); scrollingInfo.Algorithm.Reset(); @@ -186,39 +194,52 @@ namespace osu.Game.Rulesets.UI.Scrolling } } - private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) + /// + /// Get a conservative maximum bounding box of a corresponding to . + /// It is used to calculate when the hit object appears. + /// + protected virtual RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) => new RectangleF().Inflate(100); + + private double computeDisplayStartTime(HitObjectLifetimeEntry entry) { - // Origin position may be relative to the parent size - Debug.Assert(hitObject.Parent != null); + RectangleF boundingBox = GetConservativeBoundingBox(entry); + float startOffset = 0; - float originAdjustment = 0.0f; - - // calculate the dimension of the part of the hitobject that should already be visible - // when the hitobject origin first appears inside the scrolling container switch (direction.Value) { - case ScrollingDirection.Up: - originAdjustment = hitObject.OriginPosition.Y; + case ScrollingDirection.Right: + startOffset = boundingBox.Right; break; case ScrollingDirection.Down: - originAdjustment = hitObject.DrawHeight - hitObject.OriginPosition.Y; + startOffset = boundingBox.Bottom; break; case ScrollingDirection.Left: - originAdjustment = hitObject.OriginPosition.X; + startOffset = -boundingBox.Left; break; - case ScrollingDirection.Right: - originAdjustment = hitObject.DrawWidth - hitObject.OriginPosition.X; + case ScrollingDirection.Up: + startOffset = -boundingBox.Top; break; } - return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); + return scrollingInfo.Algorithm.GetDisplayStartTime(entry.HitObject.StartTime, startOffset, timeRange.Value, scrollLength); } - private void updateLayoutRecursive(DrawableHitObject hitObject) + private void setComputedLifetimeStart(HitObjectLifetimeEntry entry) { + double computedStartTime = computeDisplayStartTime(entry); + + // always load the hitobject before its first judgement offset + double judgementOffset = entry.HitObject.HitWindows?.WindowFor(Scoring.HitResult.Miss) ?? 0; + entry.LifetimeStart = Math.Min(entry.HitObject.StartTime - judgementOffset, computedStartTime); + } + + private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null) + { + parentHitObjectStartTime ??= hitObject.HitObject.StartTime; + if (hitObject.HitObject is IHasDuration e) { float length = LengthAtTime(hitObject.HitObject.StartTime, e.EndTime); @@ -230,16 +251,17 @@ namespace osu.Game.Rulesets.UI.Scrolling foreach (var obj in hitObject.NestedHitObjects) { - updateLayoutRecursive(obj); + updateLayoutRecursive(obj, parentHitObjectStartTime); - // Nested hitobjects don't need to scroll, but they do need accurate positions - updatePosition(obj, hitObject.HitObject.StartTime); + // Nested hitobjects don't need to scroll, but they do need accurate positions and start lifetime + updatePosition(obj, hitObject.HitObject.StartTime, parentHitObjectStartTime); + setComputedLifetimeStart(obj.Entry); } } - private void updatePosition(DrawableHitObject hitObject, double currentTime) + private void updatePosition(DrawableHitObject hitObject, double currentTime, double? parentHitObjectStartTime = null) { - float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime); + float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime, parentHitObjectStartTime); if (scrollingAxis == Direction.Horizontal) hitObject.X = position; diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index 078f06b745..34e5b7f9de 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -38,6 +38,8 @@ namespace osu.Game.Rulesets.UI.Scrolling /// public virtual Vector2 ScreenSpacePositionAtTime(double time) => HitObjectContainer.ScreenSpacePositionAtTime(time); - protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); + protected sealed override HitObjectContainer CreateHitObjectContainer() => CreateScrollingHitObjectContainer(); + + protected virtual ScrollingHitObjectContainer CreateScrollingHitObjectContainer() => new ScrollingHitObjectContainer(); } } diff --git a/osu.Game/Scoring/Drawables/UnprocessedPerformancePointsPlaceholder.cs b/osu.Game/Scoring/Drawables/UnprocessedPerformancePointsPlaceholder.cs new file mode 100644 index 0000000000..6087ca9eb9 --- /dev/null +++ b/osu.Game/Scoring/Drawables/UnprocessedPerformancePointsPlaceholder.cs @@ -0,0 +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 osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Scoring.Drawables +{ + /// + /// A placeholder used in PP columns for scores with unprocessed PP value. + /// + public class UnprocessedPerformancePointsPlaceholder : SpriteIcon, IHasTooltip + { + public LocalisableString TooltipText => ScoresStrings.StatusProcessing; + + public UnprocessedPerformancePointsPlaceholder() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Icon = FontAwesome.Solid.ExclamationTriangle; + } + } +} diff --git a/osu.Game/Scoring/EFScoreInfo.cs b/osu.Game/Scoring/EFScoreInfo.cs deleted file mode 100644 index 8a5bbbf0da..0000000000 --- a/osu.Game/Scoring/EFScoreInfo.cs +++ /dev/null @@ -1,272 +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 System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using Newtonsoft.Json; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; -using osu.Game.Users; -using osu.Game.Utils; - -namespace osu.Game.Scoring -{ - [Table(@"ScoreInfo")] - public class EFScoreInfo : IScoreInfo, IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable, IDeepCloneable - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public ScoreRank Rank { get; set; } - - public long TotalScore { get; set; } - - [Column(TypeName = "DECIMAL(1,4)")] // TODO: This data type is wrong (should contain more precision). But at the same time, we probably don't need to be storing this in the database. - public double Accuracy { get; set; } - - public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); - - public double? PP { get; set; } - - public int MaxCombo { get; set; } - - public int Combo { get; set; } // Todo: Shouldn't exist in here - - public int RulesetID { get; set; } - - [NotMapped] - public bool Passed { get; set; } = true; - - public EFRulesetInfo Ruleset { get; set; } - - private APIMod[] localAPIMods; - - private Mod[] mods; - - [NotMapped] - public Mod[] Mods - { - get - { - var rulesetInstance = Ruleset?.CreateInstance(); - if (rulesetInstance == null) - return mods ?? Array.Empty(); - - Mod[] scoreMods = Array.Empty(); - - if (mods != null) - scoreMods = mods; - else if (localAPIMods != null) - scoreMods = APIMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - - return scoreMods; - } - set - { - localAPIMods = null; - mods = value; - } - } - - // Used for API serialisation/deserialisation. - [NotMapped] - public APIMod[] APIMods - { - get - { - if (localAPIMods != null) - return localAPIMods; - - if (mods == null) - return Array.Empty(); - - return localAPIMods = mods.Select(m => new APIMod(m)).ToArray(); - } - set - { - localAPIMods = value; - - // We potentially can't update this yet due to Ruleset being late-bound, so instead update on read as necessary. - mods = null; - } - } - - // Used for database serialisation/deserialisation. - [Column("Mods")] - public string ModsJson - { - get => JsonConvert.SerializeObject(APIMods); - set => APIMods = !string.IsNullOrEmpty(value) ? JsonConvert.DeserializeObject(value) : Array.Empty(); - } - - [NotMapped] - public APIUser User { get; set; } - - [Column("User")] - public string UserString - { - get => User?.Username; - set - { - User ??= new APIUser(); - User.Username = value; - } - } - - [Column("UserID")] - public int? UserID - { - get => User?.Id ?? 1; - set - { - User ??= new APIUser(); - User.Id = value ?? 1; - } - } - - public int BeatmapInfoID { get; set; } - - [Column("Beatmap")] - public EFBeatmapInfo BeatmapInfo { get; set; } - - private long? onlineID; - - [JsonProperty("id")] - [Column("OnlineScoreID")] - public long? OnlineID - { - get => onlineID; - set => onlineID = value > 0 ? value : null; - } - - public DateTimeOffset Date { get; set; } - - [NotMapped] - public Dictionary Statistics { get; set; } = new Dictionary(); - - [Column("Statistics")] - public string StatisticsJson - { - get => JsonConvert.SerializeObject(Statistics); - set - { - if (value == null) - { - Statistics.Clear(); - return; - } - - Statistics = JsonConvert.DeserializeObject>(value); - } - } - - [NotMapped] - public List HitEvents { get; set; } - - public List Files { get; } = new List(); - - public string Hash { get; set; } - - public bool DeletePending { get; set; } - - /// - /// The position of this score, starting at 1. - /// - [NotMapped] - public int? Position { get; set; } // TODO: remove after all calls to `CreateScoreInfo` are gone. - - /// - /// Whether this represents a legacy (osu!stable) score. - /// - [NotMapped] - public bool IsLegacyScore => Mods.OfType().Any(); - - public IEnumerable GetStatisticsForDisplay() - { - foreach (var r in Ruleset.CreateInstance().GetHitResults()) - { - int value = Statistics.GetValueOrDefault(r.result); - - switch (r.result) - { - case HitResult.SmallTickHit: - { - int total = value + Statistics.GetValueOrDefault(HitResult.SmallTickMiss); - if (total > 0) - yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); - - break; - } - - case HitResult.LargeTickHit: - { - int total = value + Statistics.GetValueOrDefault(HitResult.LargeTickMiss); - if (total > 0) - yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); - - break; - } - - case HitResult.SmallTickMiss: - case HitResult.LargeTickMiss: - break; - - default: - yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); - - break; - } - } - } - - public EFScoreInfo DeepClone() - { - var clone = (EFScoreInfo)MemberwiseClone(); - - clone.Statistics = new Dictionary(clone.Statistics); - - return clone; - } - - public override string ToString() => this.GetDisplayTitle(); - - public bool Equals(EFScoreInfo other) - { - if (ReferenceEquals(this, other)) return true; - if (other == null) return false; - - if (ID != 0 && other.ID != 0) - return ID == other.ID; - - return false; - } - - #region Implementation of IHasOnlineID - - long IHasOnlineID.OnlineID => OnlineID ?? -1; - - #endregion - - #region Implementation of IScoreInfo - - IBeatmapInfo IScoreInfo.Beatmap => BeatmapInfo; - IRulesetInfo IScoreInfo.Ruleset => Ruleset; - IUser IScoreInfo.User => User; - bool IScoreInfo.HasReplay => Files.Any(); - - #endregion - - IEnumerable IHasNamedFiles.Files => Files; - } -} 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/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index 0219111e5c..e42f6caf26 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Collections.Generic; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring.Legacy @@ -13,6 +14,9 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { + case 1: + return getCount(scoreInfo, HitResult.LargeBonus); + case 3: return getCount(scoreInfo, HitResult.Perfect); } @@ -24,6 +28,12 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { + // For legacy scores, Geki indicates hit300 + perfect strong note hit. + // Lazer only has one result for a perfect strong note hit (LargeBonus). + case 1: + scoreInfo.Statistics[HitResult.LargeBonus] = scoreInfo.Statistics.GetValueOrDefault(HitResult.LargeBonus) + value; + break; + case 3: scoreInfo.Statistics[HitResult.Perfect] = value; break; @@ -38,11 +48,15 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { - case 3: - return getCount(scoreInfo, HitResult.Good); + // For taiko, Katu is bundled into Geki. + case 1: + break; case 2: return getCount(scoreInfo, HitResult.SmallTickMiss); + + case 3: + return getCount(scoreInfo, HitResult.Good); } return null; @@ -52,13 +66,19 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { - case 3: - scoreInfo.Statistics[HitResult.Good] = value; + // For legacy scores, Katu indicates hit100 + perfect strong note hit. + // Lazer only has one result for a perfect strong note hit (LargeBonus). + case 1: + scoreInfo.Statistics[HitResult.LargeBonus] = scoreInfo.Statistics.GetValueOrDefault(HitResult.LargeBonus) + value; break; case 2: scoreInfo.Statistics[HitResult.SmallTickMiss] = value; break; + + case 3: + scoreInfo.Statistics[HitResult.Good] = value; + break; } } diff --git a/osu.Game/Scoring/LegacyDatabasedScore.cs b/osu.Game/Scoring/LegacyDatabasedScore.cs index de35cb5faf..a7641c7999 100644 --- a/osu.Game/Scoring/LegacyDatabasedScore.cs +++ b/osu.Game/Scoring/LegacyDatabasedScore.cs @@ -25,7 +25,12 @@ namespace osu.Game.Scoring return; using (var stream = store.GetStream(replayFilename)) + { + if (stream == null) + return; + Replay = new DatabasedLegacyScoreDecoder(rulesets, beatmaps).Parse(stream).Replay; + } } } } diff --git a/osu.Game/Scoring/ScoreFileInfo.cs b/osu.Game/Scoring/ScoreFileInfo.cs deleted file mode 100644 index 6c3509bccd..0000000000 --- a/osu.Game/Scoring/ScoreFileInfo.cs +++ /dev/null @@ -1,31 +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.ComponentModel.DataAnnotations; -using osu.Game.Database; -using osu.Game.IO; - -namespace osu.Game.Scoring -{ - public class ScoreFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int ScoreInfoID { get; set; } - - public EFScoreInfo ScoreInfo { get; set; } - - public int FileInfoID { get; set; } - - public FileInfo FileInfo { get; set; } - - [Required] - public string Filename { get; set; } - - IFileInfo INamedFileUsage.File => FileInfo; - } -} diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index f59ffc7c94..5c8e21014c 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using Newtonsoft.Json; @@ -13,6 +14,11 @@ using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Rulesets; using osu.Game.Scoring.Legacy; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; using Realms; namespace osu.Game.Scoring @@ -26,11 +32,14 @@ namespace osu.Game.Scoring private readonly RulesetStore rulesets; private readonly Func beatmaps; - public ScoreImporter(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm) + private readonly IAPIProvider api; + + public ScoreImporter(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api) : base(storage, realm) { this.rulesets = rulesets; this.beatmaps = beatmaps; + this.api = api; } protected override ScoreInfo? CreateModel(ArchiveReader archive) @@ -65,8 +74,87 @@ namespace osu.Game.Scoring if (model.BeatmapInfo == null) throw new ArgumentNullException(nameof(model.BeatmapInfo)); if (model.Ruleset == null) throw new ArgumentNullException(nameof(model.Ruleset)); + PopulateMaximumStatistics(model); + if (string.IsNullOrEmpty(model.StatisticsJson)) model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); + + if (string.IsNullOrEmpty(model.MaximumStatisticsJson)) + model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); + } + + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public void PopulateMaximumStatistics(ScoreInfo score) + { + if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0) + return; + + var beatmap = score.BeatmapInfo.Detach(); + var ruleset = score.Ruleset.Detach(); + var rulesetInstance = ruleset.CreateInstance(); + + Debug.Assert(rulesetInstance != null); + + // Populate the maximum statistics. + HitResult maxBasicResult = rulesetInstance.GetHitResults() + .Select(h => h.result) + .Where(h => h.IsBasic()) + .OrderByDescending(Judgement.ToNumericResult).First(); + + foreach ((HitResult result, int count) in score.Statistics) + { + switch (result) + { + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count; + break; + + case HitResult.SmallTickHit: + case HitResult.SmallTickMiss: + score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count; + break; + + case HitResult.IgnoreHit: + case HitResult.IgnoreMiss: + case HitResult.SmallBonus: + case HitResult.LargeBonus: + break; + + default: + score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count; + break; + } + } + + if (!score.IsLegacyScore) + return; + +#pragma warning disable CS0618 + // In osu! and osu!mania, some judgements affect combo but aren't stored to scores. + // A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes. + var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap)); + var attributes = calculator.Calculate(score.Mods); + + int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum(); + if (attributes.MaxCombo > maxComboFromStatistics) + score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics; +#pragma warning restore CS0618 + } + + protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport) + { + base.PostImport(model, realm, batchImport); + + var userRequest = new GetUserRequest(model.RealmUser.Username); + + api.Perform(userRequest); + + if (userRequest.Response is APIUser user) + model.User = user; } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index cbe9615380..1b36ae176d 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -45,7 +45,7 @@ namespace osu.Game.Scoring public double Accuracy { get; set; } - public bool HasReplay { get; set; } + public bool HasReplay => !string.IsNullOrEmpty(Hash); public DateTimeOffset Date { get; set; } @@ -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(); @@ -85,8 +88,9 @@ namespace osu.Game.Scoring { get => user ??= new APIUser { - Username = RealmUser.Username, Id = RealmUser.OnlineID, + Username = RealmUser.Username, + CountryCode = RealmUser.CountryCode, }; set { @@ -95,7 +99,8 @@ namespace osu.Game.Scoring RealmUser = new RealmUser { OnlineID = user.OnlineID, - Username = user.Username + Username = user.Username, + CountryCode = user.CountryCode, }; } } @@ -131,10 +136,17 @@ namespace osu.Game.Scoring var clone = (ScoreInfo)this.Detach().MemberwiseClone(); clone.Statistics = new Dictionary(clone.Statistics); + clone.MaximumStatistics = new Dictionary(clone.MaximumStatistics); + + // Ensure we have fresh mods to avoid any references (ie. after gameplay). + clone.clearAllMods(); + clone.ModsJson = ModsJson; + clone.RealmUser = new RealmUser { OnlineID = RealmUser.OnlineID, Username = RealmUser.Username, + CountryCode = RealmUser.CountryCode, }; return clone; @@ -178,6 +190,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/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 6ee1d11f83..8342d3bcc1 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -11,9 +11,7 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Platform; -using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; @@ -21,25 +19,22 @@ using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; +using osu.Game.Online.API; namespace osu.Game.Scoring { public class ScoreManager : ModelManager, IModelImporter { - private readonly Scheduler scheduler; - private readonly BeatmapDifficultyCache difficultyCache; private readonly OsuConfigManager configManager; private readonly ScoreImporter scoreImporter; - public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, Scheduler scheduler, - BeatmapDifficultyCache difficultyCache = null, OsuConfigManager configManager = null) + public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, + OsuConfigManager configManager = null) : base(storage, realm) { - this.scheduler = scheduler; - this.difficultyCache = difficultyCache; this.configManager = configManager; - scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm) + scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api) { PostNotification = obj => PostNotification?.Invoke(obj) }; @@ -61,28 +56,12 @@ namespace osu.Game.Scoring /// Orders an array of s by total score. /// /// The array of s to reorder. - /// A to cancel the process. /// The given ordered by decreasing total score. - public async Task OrderByTotalScoreAsync(ScoreInfo[] scores, CancellationToken cancellationToken = default) - { - if (difficultyCache != null) - { - // Compute difficulties asynchronously first to prevent blocking via the GetTotalScore() call below. - foreach (var s in scores) - { - await difficultyCache.GetDifficultyAsync(s.BeatmapInfo, s.Ruleset, s.Mods, cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - } - } - - long[] totalScores = await Task.WhenAll(scores.Select(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken))).ConfigureAwait(false); - - return scores.Select((score, index) => (score, totalScore: totalScores[index])) - .OrderByDescending(g => g.totalScore) - .ThenBy(g => g.score.OnlineID) - .Select(g => g.score) - .ToArray(); - } + public IEnumerable OrderByTotalScore(IEnumerable scores) + => scores.OrderByDescending(s => GetTotalScore(s)) + .ThenBy(s => s.OnlineID) + // Local scores may not have an online ID. Fall back to date in these cases. + .ThenBy(s => s.Date); /// /// Retrieves a bindable that represents the total score of a . @@ -104,80 +83,31 @@ namespace osu.Game.Scoring /// The bindable containing the formatted total score string. public Bindable GetBindableTotalScoreString([NotNull] ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); - /// - /// Retrieves the total score of a in the given . - /// The score is returned in a callback that is run on the update thread. - /// - /// The to calculate the total score of. - /// The callback to be invoked with the total score. - /// The to return the total score as. - /// A to cancel the process. - public void GetTotalScore([NotNull] ScoreInfo score, [NotNull] Action callback, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) - { - GetTotalScoreAsync(score, mode, cancellationToken) - .ContinueWith(task => scheduler.Add(() => - { - if (!cancellationToken.IsCancellationRequested) - callback(task.GetResultSafely()); - }), TaskContinuationOptions.OnlyOnRanToCompletion); - } - /// /// Retrieves the total score of a in the given . /// /// The to calculate the total score of. /// The to return the total score as. - /// A to cancel the process. /// The total score. - public async Task GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) + public long GetTotalScore([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised) { // TODO: This is required for playlist aggregate scores. They should likely not be getting here in the first place. if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) return score.TotalScore; - int? beatmapMaxCombo = await GetMaximumAchievableComboAsync(score, cancellationToken).ConfigureAwait(false); - if (beatmapMaxCombo == null) - return score.TotalScore; - - if (beatmapMaxCombo == 0) - return 0; - var ruleset = score.Ruleset.CreateInstance(); var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = score.Mods; - return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo.Value)); + return (long)Math.Round(scoreProcessor.ComputeScore(mode, score)); } /// /// Retrieves the maximum achievable combo for the provided score. /// /// The to compute the maximum achievable combo for. - /// A to cancel the process. - /// The maximum achievable combo. A return value indicates the difficulty cache has failed to retrieve the combo. - public async Task GetMaximumAchievableComboAsync([NotNull] ScoreInfo score, CancellationToken cancellationToken = default) - { - if (score.IsLegacyScore) - { - // This score is guaranteed to be an osu!stable score. - // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. -#pragma warning disable CS0618 - if (score.BeatmapInfo.MaxCombo != null) - return score.BeatmapInfo.MaxCombo.Value; -#pragma warning restore CS0618 - - if (difficultyCache == null) - return null; - - // We can compute the max combo locally after the async beatmap difficulty computation. - var difficulty = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); - return difficulty?.MaxCombo; - } - - // This is guaranteed to be a non-legacy score. - // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. - return Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); - } + /// The maximum achievable combo. + public int GetMaximumAchievableCombo([NotNull] ScoreInfo score) => score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); /// /// Provides the total score of a . Responds to changes in the currently-selected . @@ -185,10 +115,6 @@ namespace osu.Game.Scoring private class TotalScoreBindable : Bindable { private readonly Bindable scoringMode = new Bindable(); - private readonly ScoreInfo score; - private readonly ScoreManager scoreManager; - - private CancellationTokenSource difficultyCalculationCancellationSource; /// /// Creates a new . @@ -198,19 +124,8 @@ namespace osu.Game.Scoring /// The config. public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager, OsuConfigManager configManager) { - this.score = score; - this.scoreManager = scoreManager; - configManager?.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); - scoringMode.BindValueChanged(onScoringModeChanged, true); - } - - private void onScoringModeChanged(ValueChangedEvent mode) - { - difficultyCalculationCancellationSource?.Cancel(); - difficultyCalculationCancellationSource = new CancellationTokenSource(); - - scoreManager.GetTotalScore(score, s => Value = s, mode.NewValue, difficultyCalculationCancellationSource.Token); + scoringMode.BindValueChanged(mode => Value = scoreManager.GetTotalScore(score, mode.NewValue), true); } } @@ -262,9 +177,17 @@ namespace osu.Game.Scoring public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) => scoreImporter.Import(notification, tasks); + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); + public Live Import(ScoreInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => scoreImporter.ImportModel(item, archive, batchImport, cancellationToken); + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public void PopulateMaximumStatistics(ScoreInfo score) => scoreImporter.PopulateMaximumStatistics(score); + #region Implementation of IPresentImports public Action>> PresentImport diff --git a/osu.Game/Scoring/ScoreModelDownloader.cs b/osu.Game/Scoring/ScoreModelDownloader.cs index 8625c6c5d0..514b7a57de 100644 --- a/osu.Game/Scoring/ScoreModelDownloader.cs +++ b/osu.Game/Scoring/ScoreModelDownloader.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.Game.Database; using osu.Game.Extensions; using osu.Game.Online.API; diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index 9de8561b4a..a1916953c4 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.cs @@ -11,6 +11,10 @@ namespace osu.Game.Scoring { public enum ScoreRank { + // TODO: Localisable? + [Description(@"F")] + F = -1, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankD))] [Description(@"D")] D, diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index c794c768c6..f8546d6ed0 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -129,11 +129,19 @@ namespace osu.Game.Screens.Backgrounds } case BackgroundSource.Skin: - // default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them. - if (skin.Value is DefaultSkin || skin.Value is DefaultLegacySkin) - break; + switch (skin.Value) + { + case TrianglesSkin: + case ArgonSkin: + case DefaultLegacySkin: + // default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them. + break; + + default: + newBackground = new SkinBackground(skin.Value, getBackgroundTextureName()); + break; + } - newBackground = new SkinBackground(skin.Value, getBackgroundTextureName()); break; } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs index 82e94dc862..071bb9fdcb 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs @@ -8,13 +8,12 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -30,9 +29,9 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons public readonly RadioButton Button; private Color4 defaultBackgroundColour; - private Color4 defaultBubbleColour; + private Color4 defaultIconColour; private Color4 selectedBackgroundColour; - private Color4 selectedBubbleColour; + private Color4 selectedIconColour; private Drawable icon; @@ -50,20 +49,13 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - defaultBackgroundColour = colours.Gray3; - defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); - selectedBackgroundColour = colours.BlueDark; - selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { @@ -98,7 +90,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons return; BackgroundColour = Button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - icon.Colour = Button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; + icon.Colour = Button.Selected.Value ? selectedIconColour : defaultIconColour; } protected override SpriteText CreateText() => new OsuSpriteText diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 55302833c1..1fb5c0285d 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -6,12 +6,11 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -20,9 +19,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons internal class DrawableTernaryButton : OsuButton { private Color4 defaultBackgroundColour; - private Color4 defaultBubbleColour; + private Color4 defaultIconColour; private Color4 selectedBackgroundColour; - private Color4 selectedBubbleColour; + private Color4 selectedIconColour; private Drawable icon; @@ -38,20 +37,13 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - defaultBackgroundColour = colours.Gray3; - defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); - selectedBackgroundColour = colours.BlueDark; - selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { @@ -85,17 +77,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons switch (Button.Bindable.Value) { case TernaryState.Indeterminate: - icon.Colour = selectedBubbleColour.Darken(0.5f); + icon.Colour = selectedIconColour.Darken(0.5f); BackgroundColour = selectedBackgroundColour.Darken(0.5f); break; case TernaryState.False: - icon.Colour = defaultBubbleColour; + icon.Colour = defaultIconColour; BackgroundColour = defaultBackgroundColour; break; case TernaryState.True: - icon.Colour = selectedBubbleColour; + icon.Colour = selectedIconColour; BackgroundColour = selectedBackgroundColour; break; } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs index b2007273e8..b61fcf4482 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.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 osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,13 +16,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts public class EffectPointVisualisation : CompositeDrawable, IControlPointVisualisation { private readonly EffectControlPoint effect; - private Bindable kiai; + private Bindable kiai = null!; [Resolved] - private EditorBeatmap beatmap { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; public EffectPointVisualisation(EffectControlPoint point) { @@ -38,37 +36,61 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts private void load() { kiai = effect.KiaiModeBindable.GetBoundCopy(); - kiai.BindValueChanged(_ => + kiai.BindValueChanged(_ => refreshDisplay(), true); + } + + private EffectControlPoint? nextControlPoint; + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Due to the limitations of ControlPointInfo, it's impossible to know via event flow when the next kiai point has changed. + // This is due to the fact that an EffectPoint can be added to an existing group. We would need to bind to ItemAdded on *every* + // future group to track this. + // + // I foresee this being a potential performance issue on beatmaps with many control points, so let's limit how often we check + // for changes. ControlPointInfo needs a refactor to make this flow better, but it should do for now. + Scheduler.AddDelayed(() => { - ClearInternal(); + var next = beatmap.ControlPointInfo.EffectPoints.FirstOrDefault(c => c.Time > effect.Time); - AddInternal(new ControlPointVisualisation(effect)); - - if (!kiai.Value) - return; - - var endControlPoint = beatmap.ControlPointInfo.EffectPoints.FirstOrDefault(c => c.Time > effect.Time && !c.KiaiMode); - - // handle kiai duration - // eventually this will be simpler when we have control points with durations. - if (endControlPoint != null) + if (!ReferenceEquals(nextControlPoint, next)) { - RelativeSizeAxes = Axes.Both; - Origin = Anchor.TopLeft; - - Width = (float)(endControlPoint.Time - effect.Time); - - AddInternal(new PointVisualisation - { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.TopLeft, - Width = 1, - Height = 0.25f, - Depth = float.MaxValue, - Colour = effect.GetRepresentingColour(colours).Darken(0.5f), - }); + nextControlPoint = next; + refreshDisplay(); } - }, true); + }, 100, true); + } + + private void refreshDisplay() + { + ClearInternal(); + + AddInternal(new ControlPointVisualisation(effect)); + + if (!kiai.Value) + return; + + // handle kiai duration + // eventually this will be simpler when we have control points with durations. + if (nextControlPoint != null) + { + RelativeSizeAxes = Axes.Both; + Origin = Anchor.TopLeft; + + Width = (float)(nextControlPoint.Time - effect.Time); + + AddInternal(new PointVisualisation + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopLeft, + Width = 1, + Height = 0.25f, + Depth = float.MaxValue, + Colour = effect.GetRepresentingColour(colours).Darken(0.5f), + }); + } } // kiai sections display duration, so are required to be visualised. diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 40403e08ad..19ea2162a3 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -123,16 +123,6 @@ namespace osu.Game.Screens.Edit.Compose.Components } }, new Drawable[] - { - new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - Padding = new MarginPadding { Horizontal = 15 }, - Text = "beat snap", - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre - }, - }, - new Drawable[] { new Container { @@ -173,6 +163,16 @@ namespace osu.Game.Screens.Edit.Compose.Components } } }, + new Drawable[] + { + new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) + { + Padding = new MarginPadding { Horizontal = 15, Vertical = 8 }, + Text = "beat snap", + RelativeSizeAxes = Axes.X, + TextAnchor = Anchor.TopCentre, + }, + }, }, RowDimensions = new[] { diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 540fbf9a72..43ad270c16 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; @@ -13,7 +12,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -61,25 +59,31 @@ namespace osu.Game.Screens.Edit.Compose.Components { case NotifyCollectionChangedAction.Add: foreach (object o in args.NewItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); + { + if (blueprintMap.TryGetValue((T)o, out var blueprint)) + blueprint.Select(); + } break; case NotifyCollectionChangedAction.Remove: foreach (object o in args.OldItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); + { + if (blueprintMap.TryGetValue((T)o, out var blueprint)) + blueprint.Deselect(); + } break; } }; SelectionHandler = CreateSelectionHandler(); - SelectionHandler.DeselectAll = deselectAll; + SelectionHandler.DeselectAll = DeselectAll; SelectionHandler.SelectedItems.BindTo(SelectedItems); AddRangeInternal(new[] { - DragBox = CreateDragBox(selectBlueprintsFromDragRectangle), + DragBox = CreateDragBox(), SelectionHandler, SelectionBlueprints = CreateSelectionBlueprintContainer(), SelectionHandler.CreateProxy(), @@ -101,12 +105,7 @@ namespace osu.Game.Screens.Edit.Compose.Components [CanBeNull] protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; - protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); - - /// - /// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to. - /// - protected virtual bool AllowDeselectionDuringDrag => true; + protected virtual DragBox CreateDragBox() => new DragBox(); protected override bool OnMouseDown(MouseDownEvent e) { @@ -142,7 +141,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (endClickSelection(e) || ClickedBlueprint != null) return true; - deselectAll(); + DeselectAll(); return true; } @@ -171,11 +170,15 @@ namespace osu.Game.Screens.Edit.Compose.Components finishSelectionMovement(); } + private MouseButtonEvent lastDragEvent; + protected override bool OnDragStart(DragStartEvent e) { if (e.Button == MouseButton.Right) return false; + lastDragEvent = e; + if (movementBlueprints != null) { isDraggingBlueprint = true; @@ -183,30 +186,21 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - if (DragBox.HandleDrag(e)) - { - DragBox.Show(); - return true; - } - - return false; + DragBox.HandleDrag(e); + DragBox.Show(); + return true; } protected override void OnDrag(DragEvent e) { - if (e.Button == MouseButton.Right) - return; - - if (DragBox.State == Visibility.Visible) - DragBox.HandleDrag(e); + lastDragEvent = e; moveCurrentSelection(e); } protected override void OnDragEnd(DragEndEvent e) { - if (e.Button == MouseButton.Right) - return; + lastDragEvent = null; if (isDraggingBlueprint) { @@ -214,8 +208,19 @@ namespace osu.Game.Screens.Edit.Compose.Components changeHandler?.EndChange(); } - if (DragBox.State == Visibility.Visible) - DragBox.Hide(); + DragBox.Hide(); + } + + protected override void Update() + { + base.Update(); + + if (lastDragEvent != null && DragBox.State == Visibility.Visible) + { + lastDragEvent.Target = this; + DragBox.HandleDrag(lastDragEvent); + UpdateSelectionFromDragBox(); + } } /// @@ -233,7 +238,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (!SelectionHandler.SelectedBlueprints.Any()) return false; - deselectAll(); + DeselectAll(); return true; } @@ -292,7 +297,7 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.Selected -= OnBlueprintSelected; blueprint.Deselected -= OnBlueprintDeselected; - SelectionBlueprints.Remove(blueprint); + SelectionBlueprints.Remove(blueprint, true); if (movementBlueprints?.Contains(blueprint) == true) finishSelectionMovement(); @@ -380,44 +385,39 @@ namespace osu.Game.Screens.Edit.Compose.Components } /// - /// Select all masks in a given rectangle selection area. + /// Select all blueprints in a selection area specified by . /// - /// The rectangle to perform a selection on in screen-space coordinates. - private void selectBlueprintsFromDragRectangle(RectangleF rect) + protected virtual void UpdateSelectionFromDragBox() { + var quad = DragBox.Box.ScreenSpaceDrawQuad; + foreach (var blueprint in SelectionBlueprints) { - // only run when utmost necessary to avoid unnecessary rect computations. - bool isValidForSelection() => blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint); - switch (blueprint.State) { - case SelectionState.NotSelected: - if (isValidForSelection()) - blueprint.Select(); + case SelectionState.Selected: + // Selection is preserved even after blueprint becomes dead. + if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + blueprint.Deselect(); break; - case SelectionState.Selected: - if (AllowDeselectionDuringDrag && !isValidForSelection()) - blueprint.Deselect(); + case SelectionState.NotSelected: + if (blueprint.IsAlive && blueprint.IsPresent && quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + blueprint.Select(); break; } } } /// - /// Selects all s. + /// Select all currently-present items. /// - protected virtual void SelectAll() - { - // Scheduled to allow the change in lifetime to take place. - Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select())); - } + protected abstract void SelectAll(); /// - /// Deselects all selected s. + /// Deselect all selected items. /// - private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); + protected void DeselectAll() => SelectedItems.Clear(); protected virtual void OnBlueprintSelected(SelectionBlueprint blueprint) { diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 4c37d200bc..ec07da43a0 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -12,7 +12,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics.UserInterface; @@ -37,7 +36,6 @@ namespace osu.Game.Screens.Edit.Compose.Components protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler; private PlacementBlueprint currentPlacement; - private InputManager inputManager; /// /// Positional input must be received outside the container's bounds, @@ -66,8 +64,6 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); - inputManager = GetContainingInputManager(); - Beatmap.HitObjectAdded += hitObjectAdded; // updates to selected are handled for us by SelectionHandler. @@ -220,7 +216,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position); // if no time was found from positional snapping, we should still quantize to the beat. snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); diff --git a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs index 838562719d..905d47533a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs @@ -8,7 +8,6 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Layout; @@ -21,18 +20,13 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public class DragBox : CompositeDrawable, IStateful { - protected readonly Action PerformSelection; - - protected Drawable Box; + public Drawable Box { get; private set; } /// /// Creates a new . /// - /// A delegate that performs drag selection. - public DragBox(Action performSelection) + public DragBox() { - PerformSelection = performSelection; - RelativeSizeAxes = Axes.Both; AlwaysPresent = true; Alpha = 0; @@ -46,30 +40,14 @@ namespace osu.Game.Screens.Edit.Compose.Components protected virtual Drawable CreateBox() => new BoxWithBorders(); - private RectangleF? dragRectangle; - /// /// Handle a forwarded mouse event. /// /// The mouse event. - /// Whether the event should be handled and blocking. - public virtual bool HandleDrag(MouseButtonEvent e) + public virtual void HandleDrag(MouseButtonEvent e) { - var dragPosition = e.ScreenSpaceMousePosition; - var dragStartPosition = e.ScreenSpaceMouseDownPosition; - - var dragQuad = new Quad(dragStartPosition.X, dragStartPosition.Y, dragPosition.X - dragStartPosition.X, dragPosition.Y - dragStartPosition.Y); - - // We use AABBFloat instead of RectangleF since it handles negative sizes for us - var rec = dragQuad.AABBFloat; - dragRectangle = rec; - - var topLeft = ToLocalSpace(rec.TopLeft); - var bottomRight = ToLocalSpace(rec.BottomRight); - - Box.Position = topLeft; - Box.Size = bottomRight - topLeft; - return true; + Box.Position = Vector2.ComponentMin(e.MouseDownPosition, e.MousePosition); + Box.Size = Vector2.ComponentMax(e.MouseDownPosition, e.MousePosition) - Box.Position; } private Visibility state; @@ -87,19 +65,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - protected override void Update() - { - base.Update(); - - if (dragRectangle != null) - PerformSelection?.Invoke(dragRectangle.Value); - } - - public override void Hide() - { - State = Visibility.Hidden; - dragRectangle = null; - } + public override void Hide() => State = Visibility.Hidden; public override void Show() => State = Visibility.Visible; diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 6a4fe27f04..7423b368b4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -27,6 +28,8 @@ namespace osu.Game.Screens.Edit.Compose.Components private HitObjectUsageEventBuffer usageEventBuffer; + protected InputManager InputManager { get; private set; } + protected EditorBlueprintContainer(HitObjectComposer composer) { Composer = composer; @@ -42,6 +45,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); + InputManager = GetContainingInputManager(); + Beatmap.HitObjectAdded += AddBlueprintFor; Beatmap.HitObjectRemoved += RemoveBlueprintFor; @@ -66,8 +71,6 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints.OrderBy(b => b.Item.StartTime); - protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning; - protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) { if (!base.ApplySnapResult(blueprints, result)) @@ -133,8 +136,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override void SelectAll() { Composer.Playfield.KeepAllAlive(); - - base.SelectAll(); + SelectedItems.AddRange(Beatmap.HitObjects.Except(SelectedItems).ToArray()); } protected override void OnBlueprintSelected(SelectionBlueprint blueprint) diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 06af232111..18bb6284b8 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -24,21 +24,19 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); - editorBeatmap.HitObjectUpdated += hitObjectUpdated; + editorBeatmap.BeatmapReprocessed += SortInternal; } - private void hitObjectUpdated(HitObject _) => SortInternal(); - public override void Add(SelectionBlueprint drawable) { SortInternal(); base.Add(drawable); } - public override bool Remove(SelectionBlueprint drawable) + public override bool Remove(SelectionBlueprint drawable, bool disposeImmediately) { SortInternal(); - return base.Remove(drawable); + return base.Remove(drawable, disposeImmediately); } protected override int Compare(Drawable x, Drawable y) @@ -64,7 +62,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (result != 0) return result; } - return CompareReverseChildID(y, x); + return CompareReverseChildID(x, y); } protected override void Dispose(bool isDisposing) @@ -72,7 +70,7 @@ namespace osu.Game.Screens.Edit.Compose.Components base.Dispose(isDisposing); if (editorBeatmap != null) - editorBeatmap.HitObjectUpdated -= hitObjectUpdated; + editorBeatmap.BeatmapReprocessed -= SortInternal; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs new file mode 100644 index 0000000000..58bfaf56ff --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs @@ -0,0 +1,64 @@ +// 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.Input.Events; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A that scrolls along with the scrolling playfield. + /// + public class ScrollingDragBox : DragBox + { + public double MinTime { get; private set; } + + public double MaxTime { get; private set; } + + private double? startTime; + + private readonly ScrollingPlayfield playfield; + + public ScrollingDragBox(Playfield playfield) + { + this.playfield = playfield as ScrollingPlayfield ?? throw new ArgumentException("Playfield must be of type {nameof(ScrollingPlayfield)} to use this class.", nameof(playfield)); + } + + public override void HandleDrag(MouseButtonEvent e) + { + base.HandleDrag(e); + + startTime ??= playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMouseDownPosition); + double endTime = playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMousePosition); + + MinTime = Math.Min(startTime.Value, endTime); + MaxTime = Math.Max(startTime.Value, endTime); + + var startPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(startTime.Value)); + var endPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(endTime)); + + switch (playfield.ScrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + Box.Y = Math.Min(startPos.Y, endPos.Y); + Box.Height = Math.Max(startPos.Y, endPos.Y) - Box.Y; + break; + + case ScrollingDirection.Left: + case ScrollingDirection.Right: + Box.X = Math.Min(startPos.X, endPos.X); + Box.Width = Math.Max(startPos.X, endPos.X) - Box.X; + break; + } + } + + public override void Hide() + { + base.Hide(); + startTime = null; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 8419d3b380..269c19f846 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -305,7 +305,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void DeleteSelected() { - DeleteItems(selectedBlueprints.Select(b => b.Item)); + DeleteItems(SelectedItems.ToArray()); } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 37fc4b03b2..a73ada76f5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -5,7 +5,6 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -41,6 +40,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private EditorClock editorClock { get; set; } + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + /// /// The timeline's scroll position in the last frame. /// @@ -61,15 +63,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// private bool trackWasPlaying; - private Track track; - /// /// The timeline zoom level at a 1x zoom scale. /// private float defaultTimelineZoom; - private readonly Bindable timelineZoomScale = new BindableDouble(1.0); - public Timeline(Drawable userContent) { this.userContent = userContent; @@ -92,8 +90,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Bindable waveformOpacity; + private double trackLengthForZoom; + [BackgroundDependencyLoader] - private void load(IBindable beatmap, EditorBeatmap editorBeatmap, OsuColour colours, OsuConfigManager config) + private void load(IBindable beatmap, OsuColour colours, OsuConfigManager config) { CentreMarker centreMarker; @@ -143,23 +143,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Beatmap.BindValueChanged(b => { waveform.Waveform = b.NewValue.Waveform; - track = b.NewValue.Track; - - // todo: i don't think this is safe, the track may not be loaded yet. - if (track.Length > 0) - { - MaxZoom = getZoomLevelForVisibleMilliseconds(500); - MinZoom = getZoomLevelForVisibleMilliseconds(10000); - defaultTimelineZoom = getZoomLevelForVisibleMilliseconds(6000); - } }, true); - timelineZoomScale.Value = editorBeatmap.BeatmapInfo.TimelineZoom; - timelineZoomScale.BindValueChanged(scale => - { - Zoom = (float)(defaultTimelineZoom * scale.NewValue); - editorBeatmap.BeatmapInfo.TimelineZoom = scale.NewValue; - }, true); + Zoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom); } protected override void LoadComplete() @@ -195,8 +181,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateWaveformOpacity() => waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint); - private float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(track.Length / milliseconds)); - protected override void Update() { base.Update(); @@ -207,6 +191,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // This needs to happen after transforms are updated, but before the scroll position is updated in base.UpdateAfterChildren if (editorClock.IsRunning) scrollToTrackTime(); + + if (editorClock.TrackLength != trackLengthForZoom) + { + defaultTimelineZoom = getZoomLevelForVisibleMilliseconds(6000); + + float initialZoom = (float)(defaultTimelineZoom * (editorBeatmap.BeatmapInfo.TimelineZoom == 0 ? 1 : editorBeatmap.BeatmapInfo.TimelineZoom)); + float minimumZoom = getZoomLevelForVisibleMilliseconds(10000); + float maximumZoom = getZoomLevelForVisibleMilliseconds(500); + + SetupZoom(initialZoom, minimumZoom, maximumZoom); + + float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(editorClock.TrackLength / milliseconds)); + + trackLengthForZoom = editorClock.TrackLength; + } } protected override bool OnScroll(ScrollEvent e) @@ -221,7 +220,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void OnZoomChanged() { base.OnZoomChanged(); - timelineZoomScale.Value = Zoom / defaultTimelineZoom; + editorBeatmap.BeatmapInfo.TimelineZoom = Zoom / defaultTimelineZoom; } protected override void UpdateAfterChildren() @@ -251,16 +250,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void seekTrackToCurrent() { - if (!track.IsLoaded) - return; - - double target = Current / Content.DrawWidth * track.Length; - editorClock.Seek(Math.Min(track.Length, target)); + double target = Current / Content.DrawWidth * editorClock.TrackLength; + editorClock.Seek(Math.Min(editorClock.TrackLength, target)); } private void scrollToTrackTime() { - if (!track.IsLoaded || track.Length == 0) + if (editorClock.TrackLength == 0) return; // covers the case where the user starts playback after a drag is in progress. @@ -268,7 +264,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (handlingDragInput) editorClock.Stop(); - ScrollTo((float)(editorClock.CurrentTime / track.Length) * Content.DrawWidth, false); + ScrollTo((float)(editorClock.CurrentTime / editorClock.TrackLength) * Content.DrawWidth, false); } protected override bool OnMouseDown(MouseDownEvent e) @@ -306,12 +302,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// /// The total amount of time visible on the timeline. /// - public double VisibleRange => track.Length / Zoom; + public double VisibleRange => editorClock.TrackLength / Zoom; - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) => - new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); + public double TimeAtPosition(float x) + { + return x / Content.DrawWidth * editorClock.TrackLength; + } - private double getTimeFromPosition(Vector2 localPosition) => - (localPosition.X / Content.DrawWidth) * track.Length; + public float PositionAtTime(double time) + { + return (float)(time / editorClock.TrackLength * Content.DrawWidth); + } + + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) + { + double time = TimeAtPosition(Content.ToLocalSpace(screenSpacePosition).X); + return new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(time)); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index 97dc04b9fa..58d378154a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -78,16 +78,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline LabelText = "Waveform", Current = { Value = true }, }, - controlPointsCheckbox = new OsuCheckbox - { - LabelText = "Control Points", - Current = { Value = true }, - }, ticksCheckbox = new OsuCheckbox { LabelText = "Ticks", Current = { Value = true }, - } + }, + controlPointsCheckbox = new OsuCheckbox + { + LabelText = "BPM", + Current = { Value = true }, + }, } } } @@ -118,7 +118,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Y, Height = 0.5f, Icon = FontAwesome.Solid.SearchPlus, - Action = () => changeZoom(1) + Action = () => Timeline.AdjustZoomRelatively(1) }, new TimelineButton { @@ -127,7 +127,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Y, Height = 0.5f, Icon = FontAwesome.Solid.SearchMinus, - Action = () => changeZoom(-1) + Action = () => Timeline.AdjustZoomRelatively(-1) }, } } @@ -153,7 +153,5 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Timeline.ControlPointsVisible.BindTo(controlPointsCheckbox.Current); Timeline.TicksVisible.BindTo(ticksCheckbox.Current); } - - private void changeZoom(float change) => Timeline.Zoom += change; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 742e16d5a9..b79c2675c8 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -13,7 +12,6 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; @@ -31,10 +29,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - private DragEvent lastDragEvent; private Bindable placement; private SelectionBlueprint placementBlueprint; + private bool hitObjectDragged; + /// /// Positional input must be received outside the container's bounds, /// in order to handle timeline blueprints which are stacked offscreen. @@ -65,7 +64,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void LoadComplete() { base.LoadComplete(); - DragBox.Alpha = 0; placement = Beatmap.PlacementObject.GetBoundCopy(); placement.ValueChanged += placementChanged; @@ -77,7 +75,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { if (placementBlueprint != null) { - SelectionBlueprints.Remove(placementBlueprint); + SelectionBlueprints.Remove(placementBlueprint, true); placementBlueprint = null; } } @@ -93,24 +91,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; - protected override void OnDrag(DragEvent e) + protected override bool OnDragStart(DragStartEvent e) { - handleScrollViaDrag(e); + if (!base.ReceivePositionalInputAt(e.ScreenSpaceMouseDownPosition)) + return false; - base.OnDrag(e); - } - - protected override void OnDragEnd(DragEndEvent e) - { - base.OnDragEnd(e); - lastDragEvent = null; + return base.OnDragStart(e); } protected override void Update() { - // trigger every frame so drags continue to update selection while playback is scrolling the timeline. - if (lastDragEvent != null) - OnDrag(lastDragEvent); + if (IsDragged || hitObjectDragged) + handleScrollViaDrag(); if (Composer != null && timeline != null) { @@ -165,30 +157,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { return new TimelineHitObjectBlueprint(item) { - OnDragHandled = handleScrollViaDrag, + OnDragHandled = e => hitObjectDragged = e != null, }; } - protected override DragBox CreateDragBox(Action performSelect) => new TimelineDragBox(performSelect); + protected sealed override DragBox CreateDragBox() => new TimelineDragBox(); - private void handleScrollViaDrag(DragEvent e) + protected override void UpdateSelectionFromDragBox() { - lastDragEvent = e; + var dragBox = (TimelineDragBox)DragBox; + double minTime = dragBox.MinTime; + double maxTime = dragBox.MaxTime; - if (lastDragEvent == null) - return; + SelectedItems.RemoveAll(hitObject => !shouldBeSelected(hitObject)); - if (timeline != null) + foreach (var hitObject in Beatmap.HitObjects.Except(SelectedItems).Where(shouldBeSelected)) { - var timelineQuad = timeline.ScreenSpaceDrawQuad; - float mouseX = e.ScreenSpaceMousePosition.X; - - // scroll if in a drag and dragging outside visible extents - if (mouseX > timelineQuad.TopRight.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); - else if (mouseX < timelineQuad.TopLeft.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + Composer.Playfield.SetKeepAlive(hitObject, true); + SelectedItems.Add(hitObject); } + + bool shouldBeSelected(HitObject hitObject) + { + double midTime = (hitObject.StartTime + hitObject.GetEndTime()) / 2; + return minTime <= midTime && midTime <= maxTime; + } + } + + private void handleScrollViaDrag() + { + if (timeline == null) return; + + var timelineQuad = timeline.ScreenSpaceDrawQuad; + float mouseX = InputManager.CurrentState.Mouse.Position.X; + + // scroll if in a drag and dragging outside visible extents + if (mouseX > timelineQuad.TopRight.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); + else if (mouseX < timelineQuad.TopLeft.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); } private class SelectableAreaBackground : CompositeDrawable diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs index c026c169d6..65d9293b7e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs @@ -6,76 +6,44 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.Utils; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class TimelineDragBox : DragBox { - // the following values hold the start and end X positions of the drag box in the timeline's local space, - // but with zoom unapplied in order to be able to compensate for positional changes - // while the timeline is being zoomed in/out. - private float? selectionStart; - private float selectionEnd; + public double MinTime { get; private set; } + + public double MaxTime { get; private set; } + + private double? startTime; [Resolved] private Timeline timeline { get; set; } - public TimelineDragBox(Action performSelect) - : base(performSelect) - { - } - protected override Drawable CreateBox() => new Box { RelativeSizeAxes = Axes.Y, Alpha = 0.3f }; - public override bool HandleDrag(MouseButtonEvent e) + public override void HandleDrag(MouseButtonEvent e) { - // The dragbox should only be active if the mouseDownPosition.Y is within this drawable's bounds. - float localY = ToLocalSpace(e.ScreenSpaceMouseDownPosition).Y; - if (DrawRectangle.Top > localY || DrawRectangle.Bottom < localY) - return false; + startTime ??= timeline.TimeAtPosition(e.MouseDownPosition.X); + double endTime = timeline.TimeAtPosition(e.MousePosition.X); - selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; + MinTime = Math.Min(startTime.Value, endTime); + MaxTime = Math.Max(startTime.Value, endTime); - // only calculate end when a transition is not in progress to avoid bouncing. - if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) - selectionEnd = e.MousePosition.X / timeline.CurrentZoom; - - updateDragBoxPosition(); - return true; - } - - private void updateDragBoxPosition() - { - if (selectionStart == null) - return; - - float rescaledStart = selectionStart.Value * timeline.CurrentZoom; - float rescaledEnd = selectionEnd * timeline.CurrentZoom; - - Box.X = Math.Min(rescaledStart, rescaledEnd); - Box.Width = Math.Abs(rescaledStart - rescaledEnd); - - var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; - - // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. - boxScreenRect.Y -= boxScreenRect.Height; - boxScreenRect.Height *= 2; - - PerformSelection?.Invoke(boxScreenRect); + Box.X = timeline.PositionAtTime(MinTime); + Box.Width = timeline.PositionAtTime(MaxTime) - Box.X; } public override void Hide() { base.Hide(); - selectionStart = null; + startTime = null; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 32ebc9c3c1..974e240552 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.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; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -33,19 +31,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { private const float circle_size = 38; - private Container repeatsContainer; + private Container? repeatsContainer; - public Action OnDragHandled; + public Action? OnDragHandled = null!; [UsedImplicitly] private readonly Bindable startTime; - private Bindable indexInCurrentComboBindable; + private Bindable? indexInCurrentComboBindable; - private Bindable comboIndexBindable; - private Bindable comboIndexWithOffsetsBindable; + private Bindable? comboIndexBindable; + private Bindable? comboIndexWithOffsetsBindable; - private Bindable displayColourBindable; + private Bindable displayColourBindable = null!; private readonly ExtendableCircle circle; private readonly Border border; @@ -54,7 +52,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly OsuSpriteText comboIndexText; [Resolved] - private ISkinSource skin { get; set; } + private ISkinSource skin { get; set; } = null!; public TimelineHitObjectBlueprint(HitObject item) : base(item) @@ -124,7 +122,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline case IHasComboInformation comboInfo: indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); - indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); + indexInCurrentComboBindable.BindValueChanged(_ => + { + comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); + }, true); comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); comboIndexWithOffsetsBindable = comboInfo.ComboIndexWithOffsetsBindable.GetBoundCopy(); @@ -149,8 +150,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline updateColour(); } - private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); - private void updateColour() { Color4 colour; @@ -183,11 +182,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline colouredComponents.Colour = OsuColour.ForegroundTextColourFor(averageColour); } - private SamplePointPiece sampleOverrideDisplay; - private DifficultyPointPiece difficultyOverrideDisplay; + private SamplePointPiece? sampleOverrideDisplay; + private DifficultyPointPiece? difficultyOverrideDisplay; - private DifficultyControlPoint difficultyControlPoint; - private SampleControlPoint sampleControlPoint; + private DifficultyControlPoint difficultyControlPoint = null!; + private SampleControlPoint sampleControlPoint = null!; protected override void Update() { @@ -276,16 +275,27 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public class DragArea : Circle { - private readonly HitObject hitObject; + private readonly HitObject? hitObject; [Resolved] - private Timeline timeline { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; - public Action OnDragHandled; + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } = null!; + + [Resolved] + private Timeline timeline { get; set; } = null!; + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler? changeHandler { get; set; } + + private ScheduledDelegate? dragOperation; + + public Action? OnDragHandled; public override bool HandlePositionalInput => hitObject != null; - public DragArea(HitObject hitObject) + public DragArea(HitObject? hitObject) { this.hitObject = hitObject; @@ -356,23 +366,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline this.FadeTo(IsHovered || hasMouseDown ? 1f : 0.9f, 200, Easing.OutQuint); } - [Resolved] - private EditorBeatmap beatmap { get; set; } - - [Resolved] - private IBeatSnapProvider beatSnapProvider { get; set; } - - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } - protected override bool OnDragStart(DragStartEvent e) { changeHandler?.BeginChange(); return true; } - private ScheduledDelegate dragOperation; - protected override void OnDrag(DragEvent e) { base.OnDrag(e); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index 076ce224f0..c1c9b2493b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -4,11 +4,12 @@ #nullable disable using System; -using System.Linq; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -34,8 +35,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private OsuColour colours { get; set; } - private static readonly int highest_divisor = BindableBeatDivisor.PREDEFINED_DIVISORS.Last(); - public TimelineTickDisplay() { RelativeSizeAxes = Axes.Both; @@ -80,20 +79,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.Update(); - if (timeline != null) + if (timeline == null || DrawWidth <= 0) return; + + (float, float) newRange = ( + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); + + if (visibleRange != newRange) { - var newRange = ( - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); + visibleRange = newRange; - if (visibleRange != newRange) - { - visibleRange = newRange; - - // actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries. - if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick)) - tickCache.Invalidate(); - } + // actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries. + if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick)) + tickCache.Invalidate(); } if (!tickCache.IsValid) @@ -151,6 +149,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } + if (Children.Count > 512) + { + // There should always be a sanely small number of ticks rendered. + // If this assertion triggers, either the zoom logic is broken or a beatmap is + // probably doing weird things... + // + // Let's hope the latter never happens. + // If it does, we can choose to either fix it or ignore it as an outlier. + string message = $"Timeline is rendering many ticks ({Children.Count})"; + + Logger.Log(message); + Debug.Fail(message); + } + int usedDrawables = drawableIndex; // save a few drawables beyond the currently used for edge cases. diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 80cdef38e9..839b2b5bad 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -32,67 +32,77 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Container zoomedContent; protected override Container Content => zoomedContent; - private float currentZoom = 1; /// - /// The current zoom level of . - /// It may differ from during transitions. + /// The current zoom level of . + /// It may differ from during transitions. /// - public float CurrentZoom => currentZoom; + public float CurrentZoom { get; private set; } = 1; + + private bool isZoomSetUp; [Resolved(canBeNull: true)] private IFrameBasedClock editorClock { get; set; } private readonly LayoutValue zoomedContentWidthCache = new LayoutValue(Invalidation.DrawSize); - public ZoomableScrollContainer() + private float minZoom; + private float maxZoom; + + /// + /// Creates a with no zoom range. + /// Functionality will be disabled until zoom is set up via . + /// + protected ZoomableScrollContainer() : base(Direction.Horizontal) { - base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y }); + base.Content.Add(zoomedContent = new Container + { + RelativeSizeAxes = Axes.Y, + // We must hide content until SetupZoom is called. + // If not, a child component that relies on its DrawWidth (via RelativeSizeAxes) may see a very incorrect value + // momentarily, as noticed in the TimelineTickDisplay, which would render thousands of ticks incorrectly. + Alpha = 0, + }); AddLayout(zoomedContentWidthCache); } - private float minZoom = 1; - /// - /// The minimum zoom level allowed. + /// Creates a with a defined zoom range. /// - public float MinZoom + public ZoomableScrollContainer(float minimum, float maximum, float initial) + : this() { - get => minZoom; - set - { - if (value < 1) - throw new ArgumentException($"{nameof(MinZoom)} must be >= 1.", nameof(value)); - - minZoom = value; - - // ensure zoom range is in valid state before updating zoom. - if (MinZoom < MaxZoom) - updateZoom(); - } + SetupZoom(initial, minimum, maximum); } - private float maxZoom = 60; - /// - /// The maximum zoom level allowed. + /// Sets up the minimum and maximum range of this zoomable scroll container, along with the initial zoom value. /// - public float MaxZoom + /// The initial zoom value, applied immediately. + /// The minimum zoom value. + /// The maximum zoom value. + protected void SetupZoom(float initial, float minimum, float maximum) { - get => maxZoom; - set - { - if (value < 1) - throw new ArgumentException($"{nameof(MaxZoom)} must be >= 1.", nameof(value)); + if (minimum < 1) + throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be >= 1.", nameof(maximum)); - maxZoom = value; + if (maximum < 1) + throw new ArgumentException($"{nameof(maximum)} ({maximum}) must be >= 1.", nameof(maximum)); - // ensure zoom range is in valid state before updating zoom. - if (MaxZoom > MinZoom) - updateZoom(); - } + if (minimum > maximum) + throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be less than {nameof(maximum)} ({maximum})"); + + if (initial < minimum || initial > maximum) + throw new ArgumentException($"{nameof(initial)} ({initial}) must be between {nameof(minimum)} ({minimum}) and {nameof(maximum)} ({maximum})"); + + minZoom = minimum; + maxZoom = maximum; + CurrentZoom = zoomTarget = initial; + isZoomSetUp = true; + + zoomedContent.Show(); } /// @@ -104,19 +114,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline set => updateZoom(value); } - private void updateZoom(float? value = null) + private void updateZoom(float value) { - float newZoom = Math.Clamp(value ?? Zoom, MinZoom, MaxZoom); + if (!isZoomSetUp) + return; + + float newZoom = Math.Clamp(value, minZoom, maxZoom); if (IsLoaded) setZoomTarget(newZoom, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); else - currentZoom = zoomTarget = newZoom; + CurrentZoom = zoomTarget = newZoom; } - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); + base.UpdateAfterChildren(); if (!zoomedContentWidthCache.IsValid) updateZoomedContentWidth(); @@ -127,7 +140,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (e.AltPressed) { // zoom when holding alt. - setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); + AdjustZoomRelatively(e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); return true; } @@ -141,16 +154,28 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateZoomedContentWidth() { - zoomedContent.Width = DrawWidth * currentZoom; + zoomedContent.Width = DrawWidth * CurrentZoom; zoomedContentWidthCache.Validate(); } + public void AdjustZoomRelatively(float change, float? focusPoint = null) + { + if (!isZoomSetUp) + return; + + const float zoom_change_sensitivity = 0.02f; + + setZoomTarget(zoomTarget + change * (maxZoom - minZoom) * zoom_change_sensitivity, focusPoint); + } + private float zoomTarget = 1; - private void setZoomTarget(float newZoom, float focusPoint) + private void setZoomTarget(float newZoom, float? focusPoint = null) { - zoomTarget = Math.Clamp(newZoom, MinZoom, MaxZoom); - transformZoomTo(zoomTarget, focusPoint, ZoomDuration, ZoomEasing); + zoomTarget = Math.Clamp(newZoom, minZoom, maxZoom); + focusPoint ??= zoomedContent.ToLocalSpace(ToScreenSpace(new Vector2(DrawWidth / 2, 0))).X; + + transformZoomTo(zoomTarget, focusPoint.Value, ZoomDuration, ZoomEasing); OnZoomChanged(); } @@ -183,7 +208,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly float scrollOffset; /// - /// Transforms to a new value. + /// Transforms to a new value. /// /// The focus point in absolute coordinates local to the content. /// The size of the content. @@ -195,7 +220,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline this.scrollOffset = scrollOffset; } - public override string TargetMember => nameof(currentZoom); + public override string TargetMember => nameof(CurrentZoom); private float valueAt(double time) { @@ -213,7 +238,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float expectedWidth = d.DrawWidth * newZoom; float targetOffset = expectedWidth * (focusPoint / contentSize) - focusOffset; - d.currentZoom = newZoom; + d.CurrentZoom = newZoom; d.updateZoomedContentWidth(); // Temporarily here to make sure ScrollTo gets the correct DrawSize for scrollable area. @@ -222,7 +247,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline d.ScrollTo(targetOffset, false); } - protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.currentZoom; + protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom; } } } diff --git a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs new file mode 100644 index 0000000000..594042b426 --- /dev/null +++ b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs @@ -0,0 +1,18 @@ +// 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.Beatmaps; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public class DeleteDifficultyConfirmationDialog : DeleteConfirmationDialog + { + public DeleteDifficultyConfirmationDialog(BeatmapInfo beatmapInfo, Action deleteAction) + { + BodyText = $"\"{beatmapInfo.DifficultyName}\" difficulty"; + DeleteAction = deleteAction; + } + } +} diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 6ec9ff4e89..3dfc7010f3 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -5,11 +5,11 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -18,10 +18,12 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Framework.Timing; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -31,10 +33,11 @@ using osu.Game.Database; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Overlays.OSD; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -50,6 +53,7 @@ using osu.Game.Screens.Play; using osu.Game.Users; using osuTK.Graphics; using osuTK.Input; +using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Screens.Edit { @@ -169,6 +173,9 @@ namespace osu.Game.Screens.Edit [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Resolved(canBeNull: true)] + private OnScreenDisplay onScreenDisplay { get; set; } + public Editor(EditorLoader loader = null) { this.loader = loader; @@ -186,7 +193,7 @@ namespace osu.Game.Screens.Edit loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); // required so we can get the track length in EditorClock. - // this is safe as nothing has yet got a reference to this new beatmap. + // this is ONLY safe because the track being provided is a `TrackVirtual` which we don't really care about disposing. loadableBeatmap.LoadTrack(); // this is a bit haphazard, but guards against setting the lease Beatmap bindable if @@ -212,7 +219,7 @@ namespace osu.Game.Screens.Edit } // Todo: should probably be done at a DrawableRuleset level to share logic with Player. - clock = new EditorClock(playableBeatmap, beatDivisor) { IsCoupled = false }; + clock = new EditorClock(playableBeatmap, beatDivisor); clock.ChangeSource(loadableBeatmap.Track); dependencies.CacheAs(clock); @@ -226,6 +233,8 @@ namespace osu.Game.Screens.Edit AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin(), loadableBeatmap.BeatmapInfo)); dependencies.CacheAs(editorBeatmap); + editorBeatmap.UpdateInProgress.BindValueChanged(updateInProgress); + canSave = editorBeatmap.BeatmapInfo.Ruleset.CreateInstance() is ILegacyRuleset; if (canSave) @@ -323,6 +332,9 @@ namespace osu.Game.Screens.Edit changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); } + [Resolved] + private MusicController musicController { get; set; } + protected override void LoadComplete() { base.LoadComplete(); @@ -330,12 +342,18 @@ namespace osu.Game.Screens.Edit Mode.Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose; Mode.BindValueChanged(onModeChanged, true); + + musicController.TrackChanged += onTrackChanged; } - /// - /// If the beatmap's track has changed, this method must be called to keep the editor in a valid state. - /// - public void UpdateClockSource() => clock.ChangeSource(Beatmap.Value.Track); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + musicController.TrackChanged -= onTrackChanged; + } + + private void onTrackChanged(WorkingBeatmap working, TrackChangeDirection direction) => clock.ChangeSource(working.Track); /// /// Creates an instance representing the current state of the editor. @@ -405,6 +423,7 @@ namespace osu.Game.Screens.Edit // no longer new after first user-triggered save. isNewBeatmap = false; updateLastSavedHash(); + onScreenDisplay?.Display(new BeatmapEditorToast(ToastStrings.BeatmapSaved, editorBeatmap.BeatmapInfo.GetDisplayTitle())); return true; } @@ -697,6 +716,27 @@ namespace osu.Game.Screens.Edit this.Exit(); } + #region Mute from update application + + private ScheduledDelegate temporaryMuteRestorationDelegate; + private bool temporaryMuteFromUpdateInProgress; + + private void updateInProgress(ValueChangedEvent obj) + { + temporaryMuteFromUpdateInProgress = true; + updateSampleDisabledState(); + + // Debounce is arbitrarily high enough to avoid flip-flopping the value each other frame. + temporaryMuteRestorationDelegate?.Cancel(); + temporaryMuteRestorationDelegate = Scheduler.AddDelayed(() => + { + temporaryMuteFromUpdateInProgress = false; + updateSampleDisabledState(); + }, 50); + } + + #endregion + #region Clipboard support private EditorMenuItem cutMenuItem; @@ -812,7 +852,9 @@ namespace osu.Game.Screens.Edit private void updateSampleDisabledState() { - samplePlaybackDisabled.Value = clock.SeekingOrStopped.Value || !(currentScreen is ComposeScreen); + samplePlaybackDisabled.Value = clock.SeekingOrStopped.Value + || currentScreen is not ComposeScreen + || temporaryMuteFromUpdateInProgress; } private void seek(UIEvent e, int direction) @@ -836,35 +878,61 @@ namespace osu.Game.Screens.Edit clock.SeekForward(!trackPlaying, amount); } + private void updateLastSavedHash() + { + lastSavedHash = changeHandler?.CurrentStateHash; + } + + private List createFileMenuItems() => new List + { + new EditorMenuItem("Save", MenuItemType.Standard, () => Save()), + new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + new EditorMenuItemSpacer(), + createDifficultyCreationMenu(), + createDifficultySwitchMenu(), + new EditorMenuItemSpacer(), + new EditorMenuItem("Delete difficulty", MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }, + new EditorMenuItemSpacer(), + new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit) + }; + private void exportBeatmap() { Save(); new LegacyBeatmapExporter(storage).Export(Beatmap.Value.BeatmapSetInfo); } - private void updateLastSavedHash() - { - lastSavedHash = changeHandler?.CurrentStateHash; - } + /// + /// Beatmaps of the currently edited set, grouped by ruleset and ordered by difficulty. + /// + private IOrderedEnumerable> groupedOrderedBeatmaps => Beatmap.Value.BeatmapSetInfo.Beatmaps + .OrderBy(b => b.StarRating) + .GroupBy(b => b.Ruleset) + .OrderBy(group => group.Key); - private List createFileMenuItems() + private void deleteDifficulty() { - var fileMenuItems = new List + if (dialogOverlay == null) + delete(); + else + dialogOverlay.Push(new DeleteDifficultyConfirmationDialog(Beatmap.Value.BeatmapInfo, delete)); + + void delete() { - new EditorMenuItem("Save", MenuItemType.Standard, () => Save()) - }; + BeatmapInfo difficultyToDelete = playableBeatmap.BeatmapInfo; - if (RuntimeInfo.IsDesktop) - fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); + var difficultiesBeforeDeletion = groupedOrderedBeatmaps.SelectMany(g => g).ToList(); - fileMenuItems.Add(new EditorMenuItemSpacer()); + beatmapManager.DeleteDifficultyImmediately(difficultyToDelete); - fileMenuItems.Add(createDifficultyCreationMenu()); - fileMenuItems.Add(createDifficultySwitchMenu()); + int deletedIndex = difficultiesBeforeDeletion.IndexOf(difficultyToDelete); + // of note, we're still working with the cloned version, so indices are all prior to deletion. + BeatmapInfo nextToShow = difficultiesBeforeDeletion[deletedIndex == 0 ? 1 : deletedIndex - 1]; - fileMenuItems.Add(new EditorMenuItemSpacer()); - fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); - return fileMenuItems; + Beatmap.Value = beatmapManager.GetWorkingBeatmap(nextToShow); + + SwitchToDifficulty(nextToShow); + } } private EditorMenuItem createDifficultyCreationMenu() @@ -896,18 +964,14 @@ namespace osu.Game.Screens.Edit private EditorMenuItem createDifficultySwitchMenu() { - var beatmapSet = playableBeatmap.BeatmapInfo.BeatmapSet; - - Debug.Assert(beatmapSet != null); - var difficultyItems = new List(); - foreach (var rulesetBeatmaps in beatmapSet.Beatmaps.GroupBy(b => b.Ruleset).OrderBy(group => group.Key)) + foreach (var rulesetBeatmaps in groupedOrderedBeatmaps) { if (difficultyItems.Count > 0) difficultyItems.Add(new EditorMenuItemSpacer()); - foreach (var beatmap in rulesetBeatmaps.OrderBy(b => b.StarRating)) + foreach (var beatmap in rulesetBeatmaps) { bool isCurrentDifficulty = playableBeatmap.BeatmapInfo.Equals(beatmap); difficultyItems.Add(new DifficultyMenuItem(beatmap, isCurrentDifficulty, SwitchToDifficulty)); @@ -921,7 +985,7 @@ namespace osu.Game.Screens.Edit private void cancelExit() { - samplePlaybackDisabled.Value = false; + updateSampleDisabledState(); loader?.CancelPendingDifficultySwitch(); } @@ -933,6 +997,14 @@ namespace osu.Game.Screens.Edit ControlPointInfo IBeatSyncProvider.ControlPoints => editorBeatmap.ControlPointInfo; IClock IBeatSyncProvider.Clock => clock; - ChannelAmplitudes? IBeatSyncProvider.Amplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : null; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; + + private class BeatmapEditorToast : Toast + { + public BeatmapEditorToast(LocalisableString value, string beatmapDisplayName) + : base(InputSettingsStrings.EditorSection, value, beatmapDisplayName) + { + } + } } } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 96425e8bc8..839535b99f 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -22,6 +22,17 @@ namespace osu.Game.Screens.Edit { public class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider { + /// + /// Will become true when a new update is queued, and false when all updates have been applied. + /// + /// + /// This is intended to be used to avoid performing operations (like playback of samples) + /// while mutating hitobjects. + /// + public IBindable UpdateInProgress => updateInProgress; + + private readonly BindableBool updateInProgress = new BindableBool(); + /// /// Invoked when a is added to this . /// @@ -37,6 +48,15 @@ namespace osu.Game.Screens.Edit /// public event Action HitObjectUpdated; + /// + /// Invoked after any state changes occurred which triggered a beatmap reprocess via an . + /// + /// + /// Beatmap processing may change the order of hitobjects. This event gives external components a chance to handle any changes + /// not covered by the / / events. + /// + public event Action BeatmapReprocessed; + /// /// All currently selected s. /// @@ -78,7 +98,10 @@ namespace osu.Game.Screens.Edit this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; if (beatmapSkin is Skin skin) + { BeatmapSkin = new EditorBeatmapSkin(skin); + BeatmapSkin.BeatmapSkinChanged += SaveState; + } beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap); @@ -225,6 +248,8 @@ namespace osu.Game.Screens.Edit { // updates are debounced regardless of whether a batch is active. batchPendingUpdates.Add(hitObject); + + updateInProgress.Value = true; } /// @@ -234,6 +259,8 @@ namespace osu.Game.Screens.Edit { foreach (var h in HitObjects) batchPendingUpdates.Add(h); + + updateInProgress.Value = true; } /// @@ -313,6 +340,8 @@ namespace osu.Game.Screens.Edit beatmapProcessor?.PostProcess(); + BeatmapReprocessed?.Invoke(); + // callbacks may modify the lists so let's be safe about it var deletes = batchPendingDeletes.ToArray(); batchPendingDeletes.Clear(); @@ -323,9 +352,13 @@ namespace osu.Game.Screens.Edit var updates = batchPendingUpdates.ToArray(); batchPendingUpdates.Clear(); + foreach (var h in deletes) SelectedHitObjects.Remove(h); + foreach (var h in deletes) HitObjectRemoved?.Invoke(h); foreach (var h in inserts) HitObjectAdded?.Invoke(h); foreach (var h in updates) HitObjectUpdated?.Invoke(h); + + updateInProgress.Value = false; } /// diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index 475c894b30..a106a60379 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -8,7 +8,6 @@ using System.Linq; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Skinning; diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index c3b29afd30..6485f683ad 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -4,10 +4,12 @@ #nullable disable using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; using osu.Framework.Timing; using osu.Framework.Utils; @@ -19,7 +21,7 @@ namespace osu.Game.Screens.Edit /// /// A decoupled clock which adds editor-specific functionality, such as snapping to a user-defined beat divisor. /// - public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock + public class EditorClock : CompositeComponent, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { public IBindable Track => track; @@ -33,7 +35,7 @@ namespace osu.Game.Screens.Edit private readonly BindableBeatDivisor beatDivisor; - private readonly DecoupleableInterpolatingFramedClock underlyingClock; + private readonly FramedBeatmapClock underlyingClock; private bool playbackFinished; @@ -52,7 +54,8 @@ namespace osu.Game.Screens.Edit this.beatDivisor = beatDivisor ?? new BindableBeatDivisor(); - underlyingClock = new DecoupleableInterpolatingFramedClock(); + underlyingClock = new FramedBeatmapClock(applyOffsets: true) { IsCoupled = false }; + AddInternal(underlyingClock); } /// @@ -155,6 +158,8 @@ namespace osu.Game.Screens.Edit public double CurrentTime => underlyingClock.CurrentTime; + public double TotalAppliedOffset => underlyingClock.TotalAppliedOffset; + public void Reset() { ClearTransforms(); @@ -219,18 +224,7 @@ namespace osu.Game.Screens.Edit public void ProcessFrame() { - underlyingClock.ProcessFrame(); - - playbackFinished = CurrentTime >= TrackLength; - - if (playbackFinished) - { - if (IsRunning) - underlyingClock.Stop(); - - if (CurrentTime > TrackLength) - underlyingClock.Seek(TrackLength); - } + // Noop to ensure an external consumer doesn't process the internal clock an extra time. } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; @@ -247,18 +241,26 @@ namespace osu.Game.Screens.Edit public IClock Source => underlyingClock.Source; - public bool IsCoupled - { - get => underlyingClock.IsCoupled; - set => underlyingClock.IsCoupled = value; - } - private const double transform_time = 300; protected override void Update() { base.Update(); + // EditorClock wasn't being added in many places. This gives us more certainty that it is. + Debug.Assert(underlyingClock.LoadState > LoadState.NotLoaded); + + playbackFinished = CurrentTime >= TrackLength; + + if (playbackFinished) + { + if (IsRunning) + underlyingClock.Stop(); + + if (CurrentTime > TrackLength) + underlyingClock.Seek(TrackLength); + } + updateSeekingState(); } diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index 87e640badc..94975b6b5e 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -27,7 +27,11 @@ namespace osu.Game.Screens.Edit.GameplayTest } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) - => new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time }; + { + var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart); + masterGameplayClockContainer.Reset(editorState.Time); + return masterGameplayClockContainer; + } protected override void LoadComplete() { @@ -35,7 +39,13 @@ namespace osu.Game.Screens.Edit.GameplayTest ScoreProcessor.HasCompleted.BindValueChanged(completed => { if (completed.NewValue) - Scheduler.AddDelayed(this.Exit, RESULTS_DISPLAY_DELAY); + { + Scheduler.AddDelayed(() => + { + if (this.IsCurrentScreen()) + this.Exit(); + }, RESULTS_DISPLAY_DELAY); + } }); } 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 40bbfeaf7d..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 } } }; @@ -126,6 +127,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning.Current.Value; Beatmap.BeatmapInfo.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; Beatmap.BeatmapInfo.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; + + Beatmap.SaveState(); } } } diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 5ce5d05d64..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, @@ -96,6 +97,7 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; Beatmap.UpdateAllHitObjects(); + Beatmap.SaveState(); } } } diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 854dec2001..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) }; @@ -87,7 +88,7 @@ namespace osu.Game.Screens.Edit.Setup target.Current.Value = value; updateReadOnlyState(); - updateMetadata(); + Scheduler.AddOnce(updateMetadata); } private void updateReadOnlyState() @@ -102,7 +103,7 @@ namespace osu.Game.Screens.Edit.Setup // for now, update on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - updateMetadata(); + Scheduler.AddOnce(updateMetadata); } private void updateMetadata() @@ -117,6 +118,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.BeatmapInfo.DifficultyName = difficultyTextBox.Current.Value; Beatmap.Metadata.Source = sourceTextBox.Current.Value; Beatmap.Metadata.Tags = tagsTextBox.Current.Value; + + Beatmap.SaveState(); } } } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 1f8381e1ed..cbaa2d8b42 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -4,13 +4,13 @@ #nullable disable using System.IO; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; 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 { @@ -19,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; } @@ -30,8 +30,8 @@ namespace osu.Game.Screens.Edit.Setup [Resolved] private IBindable working { get; set; } - [Resolved(canBeNull: true)] - private Editor editor { get; set; } + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } [Resolved] private SetupScreenHeader header { get; set; } @@ -43,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 }, @@ -78,7 +78,7 @@ namespace osu.Game.Screens.Edit.Setup // remove the previous background for now. // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.Files.FirstOrDefault(f => f.Filename == working.Value.Metadata.BackgroundFile); + var oldFile = set.GetFile(working.Value.Metadata.BackgroundFile); using (var stream = source.OpenRead()) { @@ -88,6 +88,8 @@ namespace osu.Game.Screens.Edit.Setup beatmaps.AddFile(set, stream, destination.Name); } + editorBeatmap.SaveState(); + working.Value.Metadata.BackgroundFile = destination.Name; header.Background.UpdateBackground(); @@ -105,7 +107,7 @@ namespace osu.Game.Screens.Edit.Setup // remove the previous audio track for now. // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.Files.FirstOrDefault(f => f.Filename == working.Value.Metadata.AudioFile); + var oldFile = set.GetFile(working.Value.Metadata.AudioFile); using (var stream = source.OpenRead()) { @@ -117,9 +119,9 @@ namespace osu.Game.Screens.Edit.Setup working.Value.Metadata.AudioFile = destination.Name; + editorBeatmap.SaveState(); music.ReloadCurrentTrack(); - editor?.UpdateClockSource(); return true; } @@ -142,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/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 7d8657a3df..a5739a41b1 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -41,6 +41,11 @@ namespace osu.Game.Screens /// bool HideOverlaysOnEnter { get; } + /// + /// Whether the menu cursor should be hidden when non-mouse input is received. + /// + bool HideMenuCursorOnNonMouseInput { get; } + /// /// Whether overlays should be able to be opened when this screen is current. /// diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index dbb1278d76..afba00274c 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -14,7 +14,6 @@ using osu.Game.Screens.Menu; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Configuration; -using osu.Game.Database; using osu.Game.Graphics.UserInterface; using IntroSequence = osu.Game.Configuration.IntroSequence; @@ -66,32 +65,13 @@ namespace osu.Game.Screens protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler(); - [Resolved(canBeNull: true)] - private DatabaseContextFactory efContextFactory { get; set; } - - private EFToRealmMigrator realmMigrator; - public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal); - // A non-null context factory means there's still content to migrate. - if (efContextFactory != null) - { - LoadComponentAsync(realmMigrator = new EFToRealmMigrator(), AddInternal); - realmMigrator.MigrationCompleted.ContinueWith(_ => Schedule(() => - { - // Delay initial screen loading to ensure that the migration is in a complete and sane state - // before the intro screen may import the game intro beatmap. - LoadComponentAsync(loadableScreen = CreateLoadableScreen()); - })); - } - else - { - LoadComponentAsync(loadableScreen = CreateLoadableScreen()); - } + LoadComponentAsync(loadableScreen = CreateLoadableScreen()); LoadComponentAsync(spinner = new LoadingSpinner(true, true) { diff --git a/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs b/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs new file mode 100644 index 0000000000..450c559450 --- /dev/null +++ b/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs @@ -0,0 +1,39 @@ +// 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.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class ConfirmDiscardChangesDialog : PopupDialog + { + /// + /// Construct a new discard changes confirmation dialog. + /// + /// An action to perform on confirmation. + /// An optional action to perform on cancel. + public ConfirmDiscardChangesDialog(Action onConfirm, Action? onCancel = null) + { + HeaderText = "Are you sure you want to go back?"; + BodyText = "This will discard any unsaved changes"; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = @"Yes", + Action = onConfirm + }, + new PopupDialogCancelButton + { + Text = @"No I didn't mean to", + Action = onCancel + }, + }; + } + } +} diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs index 734ff6b23f..20fa889986 100644 --- a/osu.Game/Screens/Menu/ConfirmExitDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.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; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; @@ -16,7 +14,7 @@ namespace osu.Game.Screens.Menu /// /// An action to perform on confirmation. /// An optional action to perform on cancel. - public ConfirmExitDialog(Action onConfirm, Action onCancel = null) + public ConfirmExitDialog(Action onConfirm, Action? onCancel = null) { HeaderText = "Are you sure you want to exit osu!?"; BodyText = "Last chance to turn back"; diff --git a/osu.Game/Screens/Menu/IntroCircles.cs b/osu.Game/Screens/Menu/IntroCircles.cs index faa9a267ce..7a4bdb231f 100644 --- a/osu.Game/Screens/Menu/IntroCircles.cs +++ b/osu.Game/Screens/Menu/IntroCircles.cs @@ -19,8 +19,9 @@ namespace osu.Game.Screens.Menu protected override string BeatmapFile => "circles.osz"; - private const double delay_step_one = 2300; - private const double delay_step_two = 600; + public const double TRACK_START_DELAY = 600; + + private const double delay_for_menu = 2900; private Sample welcome; @@ -50,8 +51,8 @@ namespace osu.Game.Screens.Menu PrepareMenuLoad(); - Scheduler.AddDelayed(LoadMenu, delay_step_one); - }, delay_step_two); + Scheduler.AddDelayed(LoadMenu, delay_for_menu - TRACK_START_DELAY); + }, TRACK_START_DELAY); logo.ScaleTo(1); logo.FadeIn(); diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index c1621ce78f..dcead4a3a8 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -195,10 +196,14 @@ namespace osu.Game.Screens.Menu PrepareMenuLoad(); LoadMenu(); - notifications.Post(new SimpleErrorNotification + + if (!Debugger.IsAttached) { - Text = "osu! doesn't seem to be able to play audio correctly.\n\nPlease try changing your audio device to a working setting." - }); + notifications.Post(new SimpleErrorNotification + { + Text = "osu! doesn't seem to be able to play audio correctly.\n\nPlease try changing your audio device to a working setting." + }); + } }, 5000); } @@ -267,11 +272,22 @@ namespace osu.Game.Screens.Menu FadeInBackground(200); } - protected virtual void StartTrack() + protected void StartTrack() { - // Only start the current track if it is the menu music. A beatmap's track is started when entering the Main Menu. - if (UsingThemedIntro) - Track.Start(); + var drawableTrack = musicController.CurrentTrack; + + if (!UsingThemedIntro) + { + initialBeatmap?.PrepareTrackForPreview(false, -2600); + + drawableTrack.VolumeTo(0); + drawableTrack.Restart(); + drawableTrack.VolumeTo(1, 2600, Easing.InCubic); + } + else + { + drawableTrack.Restart(); + } } protected override void LogoArriving(OsuLogo logo, bool resuming) diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index 3cdf51a87c..05a6d25303 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -72,13 +72,29 @@ namespace osu.Game.Screens.Menu RelativeSizeAxes = Axes.Both, Clock = decoupledClock, LoadMenu = LoadMenu - }, t => + }, _ => { - AddInternal(t); - if (!UsingThemedIntro) - welcome?.Play(); + AddInternal(intro); - StartTrack(); + // There is a chance that the intro timed out before being displayed, and this scheduled callback could + // happen during the outro rather than intro. + // In such a scenario, we don't want to play the intro sample, nor attempt to start the intro track + // (that may have already been since disposed by MusicController). + if (DidLoadMenu) + return; + + if (!UsingThemedIntro) + { + // If the user has requested no theme, fallback to the same intro voice and delay as IntroCircles. + // The triangles intro voice and theme are combined which makes it impossible to use. + welcome?.Play(); + Scheduler.AddDelayed(StartTrack, IntroCircles.TRACK_START_DELAY); + } + else + StartTrack(); + + // no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure. + decoupledClock.Start(); }); } } @@ -91,11 +107,6 @@ namespace osu.Game.Screens.Menu intro.Expire(); } - protected override void StartTrack() - { - decoupledClock.Start(); - } - private class TrianglesIntroSequence : CompositeDrawable { private readonly OsuLogo logo; @@ -244,8 +255,7 @@ namespace osu.Game.Screens.Menu { lazerLogo.FadeOut().OnComplete(_ => { - logoContainerSecondary.Remove(lazerLogo); - lazerLogo.Dispose(); // explicit disposal as we are pushing a new screen and the expire may not get run. + logoContainerSecondary.Remove(lazerLogo, true); logo.FadeIn(); diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index 9e56a3a0b7..5ae2158172 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -78,13 +78,17 @@ namespace osu.Game.Screens.Menu if (reverbChannel != null) intro.LogoVisualisation.AddAmplitudeSource(reverbChannel); - Scheduler.AddDelayed(() => - { + if (!UsingThemedIntro) StartTrack(); - // this classic intro loops forever. + Scheduler.AddDelayed(() => + { if (UsingThemedIntro) + { + StartTrack(); + // this classic intro loops forever. Track.Looping = true; + } const float fade_in_time = 200; diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index c66bd3639a..c7bda4d8f8 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -1,27 +1,23 @@ // 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 osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.OpenGL.Vertices; -using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.Shaders; -using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps; using System; using System.Collections.Generic; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; -using osu.Framework.Bindables; -using osu.Framework.Utils; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { @@ -30,8 +26,6 @@ namespace osu.Game.Screens.Menu /// public class LogoVisualisation : Drawable { - private readonly IBindable beatmap = new Bindable(); - /// /// The number of bars to jump each update iteration. /// @@ -76,12 +70,11 @@ namespace osu.Game.Screens.Menu private readonly float[] frequencyAmplitudes = new float[256]; - private IShader shader; - private readonly Texture texture; + private IShader shader = null!; + private Texture texture = null!; public LogoVisualisation() { - texture = Texture.WhitePixel; Blending = BlendingParameters.Additive; } @@ -93,32 +86,31 @@ namespace osu.Game.Screens.Menu } [BackgroundDependencyLoader] - private void load(ShaderManager shaders, IBindable beatmap) + private void load(IRenderer renderer, ShaderManager shaders) { - this.beatmap.BindTo(beatmap); + texture = renderer.WhitePixel; shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); } private readonly float[] temporalAmplitudes = new float[ChannelAmplitudes.AMPLITUDES_SIZE]; + [Resolved] + private IBeatSyncProvider beatSyncProvider { get; set; } = null!; + private void updateAmplitudes() { - var effect = beatmap.Value.BeatmapLoaded && beatmap.Value.TrackLoaded - ? beatmap.Value.Beatmap?.ControlPointInfo.EffectPointAt(beatmap.Value.Track.CurrentTime) - : null; - for (int i = 0; i < temporalAmplitudes.Length; i++) temporalAmplitudes[i] = 0; - if (beatmap.Value.TrackLoaded) - addAmplitudesFromSource(beatmap.Value.Track); + if (beatSyncProvider.Clock != null) + addAmplitudesFromSource(beatSyncProvider); foreach (var source in amplitudeSources) addAmplitudesFromSource(source); for (int i = 0; i < bars_per_visualiser; i++) { - float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (effect?.KiaiMode == true ? 1 : 0.5f); + float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f); if (targetAmplitude > frequencyAmplitudes[i]) frequencyAmplitudes[i] = targetAmplitude; } @@ -153,7 +145,7 @@ namespace osu.Game.Screens.Menu protected override DrawNode CreateDrawNode() => new VisualisationDrawNode(this); - private void addAmplitudesFromSource([NotNull] IHasAmplitudes source) + private void addAmplitudesFromSource(IHasAmplitudes source) { if (source == null) throw new ArgumentNullException(nameof(source)); @@ -170,8 +162,8 @@ namespace osu.Game.Screens.Menu { protected new LogoVisualisation Source => (LogoVisualisation)base.Source; - private IShader shader; - private Texture texture; + private IShader shader = null!; + private Texture texture = null!; // Assuming the logo is a circle, we don't need a second dimension. private float size; @@ -180,7 +172,7 @@ namespace osu.Game.Screens.Menu private readonly float[] audioData = new float[256]; - private readonly QuadBatch vertexBatch = new QuadBatch(100, 10); + private IVertexBatch? vertexBatch; public VisualisationDrawNode(LogoVisualisation source) : base(source) @@ -198,9 +190,11 @@ namespace osu.Game.Screens.Menu Source.frequencyAmplitudes.AsSpan().CopyTo(audioData); } - public override void Draw(Action vertexAction) + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); + + vertexBatch ??= renderer.CreateQuadBatch(100, 10); shader.Bind(); @@ -209,43 +203,40 @@ namespace osu.Game.Screens.Menu ColourInfo colourInfo = DrawColourInfo.Colour; colourInfo.ApplyChild(transparent_white); - if (audioData != null) + for (int j = 0; j < visualiser_rounds; j++) { - for (int j = 0; j < visualiser_rounds; j++) + for (int i = 0; i < bars_per_visualiser; i++) { - for (int i = 0; i < bars_per_visualiser; i++) - { - if (audioData[i] < amplitude_dead_zone) - continue; + if (audioData[i] < amplitude_dead_zone) + continue; - float rotation = MathUtils.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); - float rotationCos = MathF.Cos(rotation); - float rotationSin = MathF.Sin(rotation); - // taking the cos and sin to the 0..1 range - var barPosition = new Vector2(rotationCos / 2 + 0.5f, rotationSin / 2 + 0.5f) * size; + float rotation = MathUtils.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); + float rotationCos = MathF.Cos(rotation); + float rotationSin = MathF.Sin(rotation); + // taking the cos and sin to the 0..1 range + var barPosition = new Vector2(rotationCos / 2 + 0.5f, rotationSin / 2 + 0.5f) * size; - var barSize = new Vector2(size * MathF.Sqrt(2 * (1 - MathF.Cos(MathUtils.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); - // The distance between the position and the sides of the bar. - var bottomOffset = new Vector2(-rotationSin * barSize.X / 2, rotationCos * barSize.X / 2); - // The distance between the bottom side of the bar and the top side. - var amplitudeOffset = new Vector2(rotationCos * barSize.Y, rotationSin * barSize.Y); + var barSize = new Vector2(size * MathF.Sqrt(2 * (1 - MathF.Cos(MathUtils.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); + // The distance between the position and the sides of the bar. + var bottomOffset = new Vector2(-rotationSin * barSize.X / 2, rotationCos * barSize.X / 2); + // The distance between the bottom side of the bar and the top side. + var amplitudeOffset = new Vector2(rotationCos * barSize.Y, rotationSin * barSize.Y); - var rectangle = new Quad( - Vector2Extensions.Transform(barPosition - bottomOffset, DrawInfo.Matrix), - Vector2Extensions.Transform(barPosition - bottomOffset + amplitudeOffset, DrawInfo.Matrix), - Vector2Extensions.Transform(barPosition + bottomOffset, DrawInfo.Matrix), - Vector2Extensions.Transform(barPosition + bottomOffset + amplitudeOffset, DrawInfo.Matrix) - ); + var rectangle = new Quad( + Vector2Extensions.Transform(barPosition - bottomOffset, DrawInfo.Matrix), + Vector2Extensions.Transform(barPosition - bottomOffset + amplitudeOffset, DrawInfo.Matrix), + Vector2Extensions.Transform(barPosition + bottomOffset, DrawInfo.Matrix), + Vector2Extensions.Transform(barPosition + bottomOffset + amplitudeOffset, DrawInfo.Matrix) + ); - DrawQuad( - texture, - rectangle, - colourInfo, - null, - vertexBatch.AddAction, - // barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. - Vector2.Divide(inflation, barSize.Yx)); - } + renderer.DrawQuad( + texture, + rectangle, + colourInfo, + null, + vertexBatch.AddAction, + // barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. + Vector2.Divide(inflation, barSize.Yx)); } } @@ -256,7 +247,7 @@ namespace osu.Game.Screens.Menu { base.Dispose(isDisposing); - vertexBatch.Dispose(); + vertexBatch?.Dispose(); } } } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 066a37055c..0071ada05a 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -192,7 +192,7 @@ namespace osu.Game.Screens.Menu // presume the track is the current beatmap's track. not sure how correct this assumption is but it has worked until now. if (!track.IsRunning) { - Beatmap.Value.PrepareTrackForPreviewLooping(); + Beatmap.Value.PrepareTrackForPreview(false); track.Restart(); } } diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index ddeb544bc5..e9b50f94f7 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -90,6 +90,8 @@ namespace osu.Game.Screens.Menu private const double early_activation = 60; + private const float triangles_paused_velocity = 0.5f; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public OsuLogo() @@ -319,6 +321,11 @@ namespace osu.Game.Screens.Menu .FadeTo(visualizer_default_alpha * 1.8f * amplitudeAdjust, early_activation, Easing.Out).Then() .FadeTo(visualizer_default_alpha, beatLength); } + + this.Delay(early_activation).Schedule(() => + { + triangles.Velocity += amplitudeAdjust * (effectPoint.KiaiMode ? 6 : 3); + }); } public void PlayIntro() @@ -340,22 +347,17 @@ namespace osu.Game.Screens.Menu base.Update(); const float scale_adjust_cutoff = 0.4f; - const float velocity_adjust_cutoff = 0.98f; - const float paused_velocity = 0.5f; if (musicController.CurrentTrack.IsRunning) { float maxAmplitude = lastBeatIndex >= 0 ? musicController.CurrentTrack.CurrentAmplitudes.Maximum : 0; logoAmplitudeContainer.Scale = new Vector2((float)Interpolation.Damp(logoAmplitudeContainer.Scale.X, 1 - Math.Max(0, maxAmplitude - scale_adjust_cutoff) * 0.04f, 0.9f, Time.Elapsed)); - if (maxAmplitude > velocity_adjust_cutoff) - triangles.Velocity = 1 + Math.Max(0, maxAmplitude - velocity_adjust_cutoff) * 50; - else - triangles.Velocity = (float)Interpolation.Damp(triangles.Velocity, 1, 0.995f, Time.Elapsed); + triangles.Velocity = (float)Interpolation.Damp(triangles.Velocity, triangles_paused_velocity * (IsKiaiTime ? 4 : 2), 0.995f, Time.Elapsed); } else { - triangles.Velocity = paused_velocity; + triangles.Velocity = (float)Interpolation.Damp(triangles.Velocity, triangles_paused_velocity, 0.9f, Time.Elapsed); } } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 5193fe5cbf..ed554ebd34 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs @@ -204,6 +204,9 @@ namespace osu.Game.Screens.OnlinePlay public bool OnPressed(KeyBindingPressEvent e) { + if (!AllowSelection) + return false; + switch (e.Action) { case GlobalAction.SelectNext: @@ -224,9 +227,6 @@ namespace osu.Game.Screens.OnlinePlay private void selectNext(int direction) { - if (!AllowSelection) - return; - var visibleItems = ListContainer.AsEnumerable().Where(r => r.IsPresent); PlaylistItem item; diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index a3a176477e..bda616d5c3 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -9,17 +9,20 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -27,6 +30,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; @@ -38,7 +42,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay { - public class DrawableRoomPlaylistItem : OsuRearrangeableListItem + public class DrawableRoomPlaylistItem : OsuRearrangeableListItem, IHasContextMenu { public const float HEIGHT = 50; @@ -74,7 +78,7 @@ namespace osu.Game.Screens.OnlinePlay private IBeatmapInfo beatmap; private IRulesetInfo ruleset; - private Mod[] requiredMods; + private Mod[] requiredMods = Array.Empty(); private Container maskingContainer; private Container difficultyIconContainer; @@ -90,9 +94,15 @@ namespace osu.Game.Screens.OnlinePlay private PanelBackground panelBackground; private FillFlowContainer mainFillFlow; + [Resolved] + private RealmAccess realm { get; set; } + [Resolved] private RulesetStore rulesets { get; set; } + [Resolved] + private BeatmapManager beatmaps { get; set; } + [Resolved] private OsuColour colours { get; set; } @@ -102,6 +112,12 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private BeatmapLookupCache beatmapLookupCache { get; set; } + [Resolved(CanBeNull = true)] + private BeatmapSetOverlay beatmapOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + protected override bool ShouldBeConsideredForInput(Drawable child) => AllowReordering || AllowDeletion || !AllowSelection || SelectedItem.Value == Model; public DrawableRoomPlaylistItem(PlaylistItem item) @@ -123,7 +139,8 @@ namespace osu.Game.Screens.OnlinePlay ruleset = rulesets.GetRuleset(Item.RulesetID); var rulesetInstance = ruleset?.CreateInstance(); - requiredMods = Item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + if (rulesetInstance != null) + requiredMods = Item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); } protected override void LoadComplete() @@ -433,7 +450,7 @@ namespace osu.Game.Screens.OnlinePlay } } }, - } + }, }; } @@ -470,6 +487,31 @@ namespace osu.Game.Screens.OnlinePlay return true; } + public MenuItem[] ContextMenuItems + { + get + { + List items = new List(); + + if (beatmapOverlay != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(Item.Beatmap.OnlineID))); + + if (beatmap != null) + { + if (beatmaps.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID) is BeatmapInfo local && !local.BeatmapSet.AsNonNull().DeletePending) + { + var collectionItems = realm.Realm.All().AsEnumerable().Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast().ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + } + } + + return items.ToArray(); + } + } + public class PlaylistEditButton : GrayButton { public PlaylistEditButton() @@ -519,6 +561,10 @@ namespace osu.Game.Screens.OnlinePlay { switch (state.NewValue) { + case DownloadState.Unknown: + // Ignore initial state to ensure the button doesn't briefly appear. + break; + case DownloadState.LocallyAvailable: // Perform a local query of the beatmap by beatmap checksum, and reset the state if not matching. if (beatmapManager.QueryBeatmap(b => b.MD5Hash == beatmap.MD5Hash) == null) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index 995a0d3397..9e2bd41fd0 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -232,7 +232,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void removeUser(APIUser user) { - avatarFlow.RemoveAll(a => a.User == user); + avatarFlow.RemoveAll(a => a.User == user, true); } private void clearUsers() @@ -250,7 +250,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components hiddenUsers.Count = hiddenCount; if (displayedCircles > NumberOfCircles) - avatarFlow.Remove(avatarFlow.Last()); + avatarFlow.Remove(avatarFlow.Last(), true); else if (displayedCircles < NumberOfCircles) { var nextUser = RecentParticipants.FirstOrDefault(u => avatarFlow.All(a => a.User != u)); diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 6142fc78a8..e6b1942506 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -138,7 +138,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { foreach (var r in rooms) { - roomFlow.RemoveAll(d => d.Room == r); + roomFlow.RemoveAll(d => d.Room == r, true); // selection may have a lease due to being in a sub screen. if (!SelectedRoom.Disabled) diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs index 64644be965..ee5ee576d8 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.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.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components public class MatchLeaderboard : Leaderboard { [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } + private Bindable roomId { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -33,7 +31,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected override bool IsOnlineScope => true; - protected override APIRequest FetchScores(CancellationToken cancellationToken) + protected override APIRequest? FetchScores(CancellationToken cancellationToken) { if (roomId.Value == null) return null; diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index bae25dc9f8..00c819e5e4 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -28,6 +28,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; @@ -280,11 +281,16 @@ namespace osu.Game.Screens.OnlinePlay.Match }; } + [Resolved(canBeNull: true)] + private IDialogOverlay dialogOverlay { get; set; } + public override bool OnBackButton() { if (Room.RoomID.Value == null) { - // room has not been created yet; exit immediately. + if (!ensureExitConfirmed()) + return true; + settingsOverlay.Hide(); return base.OnBackButton(); } @@ -314,6 +320,9 @@ namespace osu.Game.Screens.OnlinePlay.Match public override void OnSuspending(ScreenTransitionEvent e) { + // Should be a noop in most cases, but let's ensure beyond doubt that the beatmap is in a correct state. + updateWorkingBeatmap(); + onLeaving(); base.OnSuspending(e); } @@ -327,8 +336,13 @@ namespace osu.Game.Screens.OnlinePlay.Match Scheduler.AddOnce(updateRuleset); } + protected bool ExitConfirmed { get; private set; } + public override bool OnExiting(ScreenExitEvent e) { + if (!ensureExitConfirmed()) + return true; + RoomManager?.PartRoom(); Mods.Value = Array.Empty(); @@ -337,6 +351,28 @@ namespace osu.Game.Screens.OnlinePlay.Match return base.OnExiting(e); } + private bool ensureExitConfirmed() + { + if (ExitConfirmed) + return true; + + if (dialogOverlay == null || Room.RoomID.Value != null || Room.Playlist.Count == 0) + return true; + + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog) + return false; + + dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + { + ExitConfirmed = true; + settingsOverlay.Hide(); + this.Exit(); + })); + + return false; + } + protected void StartPlay() { // User may be at song select or otherwise when the host starts gameplay. @@ -397,6 +433,9 @@ namespace osu.Game.Screens.OnlinePlay.Match private void updateWorkingBeatmap() { + if (SelectedItem.Value == null || !this.IsCurrentScreen()) + return; + var beatmap = SelectedItem.Value?.Beatmap; // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info @@ -449,7 +488,7 @@ namespace osu.Game.Screens.OnlinePlay.Match if (track != null) { - Beatmap.Value.PrepareTrackForPreviewLooping(); + Beatmap.Value.PrepareTrackForPreview(true); music?.EnsurePlayingSomething(); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 501c76f65b..f048ae59cd 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -109,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - if (isReady() && Client.IsHost && Room.Countdown == null) + if (isReady() && Client.IsHost && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) startMatch(); else toggleReady(); @@ -140,10 +140,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void cancelCountdown() { + if (Client.Room == null) + return; + Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation()); + MultiplayerCountdown countdown = Client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown); + Client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation()); } private void endOperation() @@ -192,7 +196,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. if (localUser?.State == MultiplayerUserState.Spectating) - readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && Room.Countdown == null; + readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown); if (newCountReady == countReady) return; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs index 14bf1a8375..cd94b47d9e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -79,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void onRoomUpdated() => Scheduler.AddOnce(() => { - bool countdownActive = multiplayerClient.Room?.Countdown is MatchStartCountdown; + bool countdownActive = multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true; if (countdownActive) { @@ -121,7 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }); } - if (multiplayerClient.Room?.Countdown != null && multiplayerClient.IsHost) + if (multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true && multiplayerClient.IsHost) { flow.Add(new OsuButton { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 3aa879dde0..bbdfed0a00 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -1,12 +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 System; using System.ComponentModel; using System.Diagnostics; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -30,12 +27,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - private MatchSettings settings; + private MatchSettings settings = null!; protected override OsuButton SubmitButton => settings.ApplyButton; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; @@ -57,19 +54,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { private const float disabled_alpha = 0.2f; - public Action SettingsApplied; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - public OsuTextBox NameField, MaxParticipantsField; - public MatchTypePicker TypePicker; - public OsuEnumDropdown QueueModeDropdown; - public OsuTextBox PasswordTextBox; - public TriangleButton ApplyButton; + public Action? SettingsApplied; - public OsuSpriteText ErrorText; + public OsuTextBox NameField = null!; + public OsuTextBox MaxParticipantsField = null!; + public MatchTypePicker TypePicker = null!; + public OsuEnumDropdown QueueModeDropdown = null!; + public OsuTextBox PasswordTextBox = null!; + public OsuCheckbox AutoSkipCheckbox = null!; + public TriangleButton ApplyButton = null!; - private OsuEnumDropdown startModeDropdown; - private OsuSpriteText typeLabel; - private LoadingLayer loadingLayer; + public OsuSpriteText ErrorText = null!; + + private OsuEnumDropdown startModeDropdown = null!; + private OsuSpriteText typeLabel = null!; + private LoadingLayer loadingLayer = null!; public void SelectBeatmap() { @@ -78,26 +79,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match } [Resolved] - private MultiplayerMatchSubScreen matchSubScreen { get; set; } + private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!; [Resolved] - private IRoomManager manager { get; set; } + private IRoomManager manager { get; set; } = null!; [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; private readonly IBindable operationInProgress = new BindableBool(); - - [CanBeNull] - private IDisposable applyingSettingsOperation; - private readonly Room room; - private Drawable playlistContainer; - private DrawableRoomPlaylist drawablePlaylist; + private IDisposable? applyingSettingsOperation; + private Drawable playlistContainer = null!; + private DrawableRoomPlaylist drawablePlaylist = null!; public MatchSettings(Room room) { @@ -249,6 +247,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match LengthLimit = 255, }, }, + new Section("Other") + { + Child = AutoSkipCheckbox = new OsuCheckbox + { + LabelText = "Automatically skip the beatmap intro" + } + } } } }, @@ -343,6 +348,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true); QueueMode.BindValueChanged(mode => QueueModeDropdown.Current.Value = mode.NewValue, true); AutoStartDuration.BindValueChanged(duration => startModeDropdown.Current.Value = (StartMode)(int)duration.NewValue.TotalSeconds, true); + AutoSkip.BindValueChanged(autoSkip => AutoSkipCheckbox.Current.Value = autoSkip.NewValue, true); operationInProgress.BindTo(ongoingOperationTracker.InProgress); operationInProgress.BindValueChanged(v => @@ -390,7 +396,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match password: PasswordTextBox.Text, matchType: TypePicker.Current.Value, queueMode: QueueModeDropdown.Current.Value, - autoStartDuration: autoStartDuration) + autoStartDuration: autoStartDuration, + autoSkip: AutoSkipCheckbox.Current.Value) .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) @@ -406,19 +413,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match room.Password.Value = PasswordTextBox.Current.Value; room.QueueMode.Value = QueueModeDropdown.Current.Value; room.AutoStartDuration.Value = autoStartDuration; + room.AutoSkip.Value = AutoSkipCheckbox.Current.Value; if (int.TryParse(MaxParticipantsField.Text, out int max)) room.MaxParticipants.Value = max; else room.MaxParticipants.Value = null; - manager?.CreateRoom(room, onSuccess, onError); + manager.CreateRoom(room, onSuccess, onError); } } private void hideError() => ErrorText.FadeOut(50); - private void onSuccess(Room room) + private void onSuccess(Room room) => Schedule(() => { Debug.Assert(applyingSettingsOperation != null); @@ -426,9 +434,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation.Dispose(); applyingSettingsOperation = null; - } + }); - private void onError(string text) + private void onError(string text) => Schedule(() => { Debug.Assert(applyingSettingsOperation != null); @@ -449,13 +457,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation.Dispose(); applyingSettingsOperation = null; - } + }); } public class CreateOrUpdateButton : TriangleButton { [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } + private Bindable roomId { get; set; } = null!; protected override void LoadComplete() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index c5d6da1ebc..b4ff34cbc2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -57,23 +57,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void onRoomUpdated() => Scheduler.AddOnce(() => { - MultiplayerCountdown newCountdown; - - switch (room?.Countdown) - { - case MatchStartCountdown: - newCountdown = room.Countdown; - break; - - // Clear the countdown with any other (including non-null) countdown values. - default: - newCountdown = null; - break; - } + MultiplayerCountdown newCountdown = room?.ActiveCountdowns.SingleOrDefault(c => c is MatchStartCountdown); if (newCountdown != countdown) { - countdown = room?.Countdown; + countdown = newCountdown; countdownChangeTime = Time.Current; } @@ -213,7 +201,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - if (room?.Host?.Equals(localUser) == true && room.Countdown == null) + if (room?.Host?.Equals(localUser) == true && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) setGreen(); else setYellow(); @@ -248,8 +236,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { get { - if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready && !room.Settings.AutoStartEnabled) + if (room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true + && multiplayerClient.IsHost + && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready + && !room.Settings.AutoStartEnabled) + { return "Cancel countdown"; + } return base.TooltipText; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index 39740e650f..ba6b482729 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -78,9 +78,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist return; bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost; + bool isValidItem = isItemOwner && !Item.Expired; - AllowDeletion = isItemOwner && !Item.Expired && Item.ID != multiplayerClient.Room.Settings.PlaylistItemId; - AllowEditing = isItemOwner && !Item.Expired; + AllowDeletion = isValidItem + && (Item.ID != multiplayerClient.Room.Settings.PlaylistItemId // This is an optimisation for the following check. + || multiplayerClient.Room.Playlist.Count(i => !i.Expired) > 1); + + AllowEditing = isValidItem; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 37b977cff7..8206d4b64d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -56,7 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer roomAccessTypeDropdown.Current.BindValueChanged(_ => UpdateFilter()); - return base.CreateFilterControls().Prepend(roomAccessTypeDropdown); + return base.CreateFilterControls().Append(roomAccessTypeDropdown); } protected override FilterCriteria CreateFilterCriteria() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index dbd679104e..3fe236bd7a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -8,11 +8,9 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; @@ -27,7 +25,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private OngoingOperationTracker operationTracker { get; set; } = null!; private readonly IBindable operationInProgress = new Bindable(); - private readonly long? itemToEdit; + private readonly PlaylistItem? itemToEdit; private LoadingLayer loadingLayer = null!; private IDisposable? selectionOperation; @@ -37,21 +35,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// /// The room. /// The item to be edited. May be null, in which case a new item will be added to the playlist. - /// An optional initial beatmap selection to perform. - /// An optional initial ruleset selection to perform. - public MultiplayerMatchSongSelect(Room room, long? itemToEdit = null, WorkingBeatmap? beatmap = null, RulesetInfo? ruleset = null) - : base(room) + public MultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) + : base(room, itemToEdit) { this.itemToEdit = itemToEdit; - - if (beatmap != null || ruleset != null) - { - Schedule(() => - { - if (beatmap != null) Beatmap.Value = beatmap; - if (ruleset != null) Ruleset.Value = ruleset; - }); - } } [BackgroundDependencyLoader] @@ -80,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { if (operationInProgress.Value) { - Logger.Log($"{nameof(SelectedItem)} aborted due to {nameof(operationInProgress)}"); + Logger.Log($"{nameof(SelectItem)} aborted due to {nameof(operationInProgress)}"); return false; } @@ -92,7 +79,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer var multiplayerItem = new MultiplayerPlaylistItem { - ID = itemToEdit ?? 0, + ID = itemToEdit?.ID ?? 0, BeatmapID = item.Beatmap.OnlineID, BeatmapChecksum = item.Beatmap.MD5Hash, RulesetID = item.RulesetID, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 869548c948..db752f2b42 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -15,6 +15,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics.Cursor; using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -48,8 +49,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } - private readonly IBindable isConnected = new Bindable(); - private AddItemButton addItemButton; public MultiplayerMatchSubScreen(Room room) @@ -69,146 +68,146 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; - isConnected.BindTo(client.IsConnected); - isConnected.BindValueChanged(connected => - { - if (!connected.NewValue) - handleRoomLost(); - }, true); + if (!client.IsConnected.Value) + handleRoomLost(); } protected override Drawable CreateMainContent() => new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new GridContainer + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Child = new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - // Participants column - new GridContainer + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + // Participants column + new GridContainer { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] { new ParticipantsListHeader() }, - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - new ParticipantsList - { - RelativeSizeAxes = Axes.Both - }, - } - } - }, - // Spacer - null, - // Beatmap column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Beatmap") }, - new Drawable[] - { - addItemButton = new AddItemButton - { - RelativeSizeAxes = Axes.X, - Height = 40, - Text = "Add item", - Action = () => OpenSongSelection() - }, + new Dimension(GridSizeMode.AutoSize) }, - null, - new Drawable[] + Content = new[] { - new MultiplayerPlaylist + new Drawable[] { new ParticipantsListHeader() }, + new Drawable[] { - RelativeSizeAxes = Axes.Both, - RequestEdit = item => OpenSongSelection(item.ID) - } - }, - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 10 }, - Alpha = 0, - Children = new Drawable[] + new ParticipantsList { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - }, + RelativeSizeAxes = Axes.Both + }, + } + } + }, + // Spacer + null, + // Beatmap column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Beatmap") }, + new Drawable[] + { + addItemButton = new AddItemButton + { + RelativeSizeAxes = Axes.X, + Height = 40, + Text = "Add item", + Action = () => OpenSongSelection() + }, + }, + null, + new Drawable[] + { + new MultiplayerPlaylist + { + RelativeSizeAxes = Axes.Both, + RequestEdit = OpenSongSelection } }, + new[] + { + UserModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + }, + } + }, + }, }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } }, - RowDimensions = new[] + // Spacer + null, + // Main right column + new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Chat") }, + new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, + } } } } @@ -218,7 +217,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// Opens the song selection screen to add or edit an item. /// /// An optional playlist item to edit. If null, a new item will be added instead. - internal void OpenSongSelection(long? itemToEdit = null) + internal void OpenSongSelection(PlaylistItem itemToEdit = null) { if (!this.IsCurrentScreen()) return; @@ -415,7 +414,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - this.Push(new MultiplayerMatchSongSelect(Room, client.Room.Settings.PlaylistItemId, beatmap, ruleset)); + this.Push(new MultiplayerMatchSongSelect(Room, Room.Playlist.Single(item => item.ID == client.Room.Settings.PlaylistItemId))); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index a82da7b185..a2c43898f7 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -9,8 +9,6 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; @@ -21,7 +19,6 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; using osu.Game.Users; -using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -41,14 +38,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private readonly TaskCompletionSource resultsReady = new TaskCompletionSource(); - private MultiplayerGameplayLeaderboard leaderboard; - private readonly MultiplayerRoomUser[] users; - private readonly Bindable leaderboardExpanded = new BindableBool(); - private LoadingLayer loadingDisplay; - private FillFlowContainer leaderboardFlow; + + private MultiplayerGameplayLeaderboard multiplayerLeaderboard; /// /// Construct a multiplayer player. @@ -61,7 +55,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowSkipping = false, + AllowSkipping = room.AutoSkip.Value, + AutomaticallySkipIntro = room.AutoSkip.Value, + AlwaysShowLeaderboard = true, }) { this.users = users; @@ -73,45 +69,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!LoadedBeatmapSuccessfully) return; - HUDOverlay.Add(leaderboardFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5) - }); - - HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); - LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); - - // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(users), l => - { - if (!LoadedBeatmapSuccessfully) - return; - - leaderboard.Expanded.BindTo(leaderboardExpanded); - - leaderboardFlow.Insert(0, l); - - if (leaderboard.TeamScores.Count >= 2) - { - LoadComponentAsync(new GameplayMatchScoreDisplay - { - Team1Score = { BindTarget = leaderboard.TeamScores.First().Value }, - Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value }, - Expanded = { BindTarget = HUDOverlay.ShowHud }, - }, scoreDisplay => leaderboardFlow.Insert(1, scoreDisplay)); - } - }); - LoadComponentAsync(new GameplayChatDisplay(Room) { - Expanded = { BindTarget = leaderboardExpanded }, - }, chat => leaderboardFlow.Insert(2, chat)); + Expanded = { BindTarget = LeaderboardExpandedState }, + }, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat)); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); } + protected override GameplayLeaderboard CreateGameplayLeaderboard() => multiplayerLeaderboard = new MultiplayerGameplayLeaderboard(users); + + protected override void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) + { + Debug.Assert(leaderboard == multiplayerLeaderboard); + + HUDOverlay.LeaderboardFlow.Insert(0, leaderboard); + + if (multiplayerLeaderboard.TeamScores.Count >= 2) + { + LoadComponentAsync(new GameplayMatchScoreDisplay + { + Team1Score = { BindTarget = multiplayerLeaderboard.TeamScores.First().Value }, + Team2Score = { BindTarget = multiplayerLeaderboard.TeamScores.Last().Value }, + Expanded = { BindTarget = HUDOverlay.ShowHud }, + }, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay)); + } + } + protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); @@ -166,9 +150,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } - private void updateLeaderboardExpandedState() => - leaderboardExpanded.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; - private void failAndBail(string message = null) { if (!string.IsNullOrEmpty(message)) @@ -177,23 +158,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Schedule(() => PerformExit(false)); } - protected override void Update() - { - base.Update(); - - if (!LoadedBeatmapSuccessfully) - return; - - adjustLeaderboardPosition(); - } - - private void adjustLeaderboardPosition() - { - const float padding = 44; // enough margin to avoid the hit error display. - - leaderboardFlow.Position = new Vector2(padding, padding + HUDOverlay.TopScoringElementsHeight); - } - private void onGameplayStarted() => Scheduler.Add(() => { if (!this.IsCurrentScreen()) @@ -231,8 +195,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { Debug.Assert(Room.RoomID.Value != null); - return leaderboard.TeamScores.Count == 2 - ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, leaderboard.TeamScores) + return multiplayerLeaderboard.TeamScores.Count == 2 + ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, multiplayerLeaderboard.TeamScores) : new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index 1f80c47d13..5a297f18db 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -110,6 +110,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (Client != null) { Client.RoomUpdated -= invokeOnRoomUpdated; + Client.LoadRequested -= invokeOnRoomLoadRequested; Client.UserLeft -= invokeUserLeft; Client.UserKicked -= invokeUserKicked; Client.UserJoined -= invokeUserJoined; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index f632951f41..3ef2f033b0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -1,8 +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 - +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -22,6 +21,7 @@ using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osu.Game.Users.Drawables; @@ -35,18 +35,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants public readonly MultiplayerRoomUser User; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private IRulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } = null!; - private SpriteIcon crown; + private SpriteIcon crown = null!; - private OsuSpriteText userRankText; - private ModDisplay userModsDisplay; - private StateDisplay userStateDisplay; + private OsuSpriteText userRankText = null!; + private ModDisplay userModsDisplay = null!; + private StateDisplay userStateDisplay = null!; - private IconButton kickButton; + private IconButton kickButton = null!; public ParticipantPanel(MultiplayerRoomUser user) { @@ -128,14 +128,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Size = new Vector2(28, 20), - Country = user?.Country + CountryCode = user?.CountryCode ?? default }, new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), - Text = user?.Username + Text = user?.Username ?? string.Empty }, userRankText = new OsuSpriteText { @@ -188,7 +188,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; var currentItem = Playlist.GetCurrentItem(); - var ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; + Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; @@ -205,10 +205,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => userModsDisplay.Current.Value = User.Mods.Select(m => m.ToMod(ruleset)).ToList()); + Schedule(() => + { + userModsDisplay.Current.Value = ruleset != null ? User.Mods.Select(m => m.ToMod(ruleset)).ToList() : Array.Empty(); + }); } - public MenuItem[] ContextMenuItems + public MenuItem[]? ContextMenuItems { get { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index cda86c74bf..7c93c6084e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -9,7 +9,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Cursor; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants @@ -24,20 +23,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants [BackgroundDependencyLoader] private void load() { - InternalChild = new OsuContextMenuContainer + InternalChild = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, - Child = new OsuScrollContainer + ScrollbarVisible = false, + Child = panels = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = panels = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2) - } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2) } }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index 9c05c19d1b..ecef7509d9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -1,10 +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 - using System; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -25,9 +22,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { private const double fade_time = 50; - private SpriteIcon icon; - private OsuSpriteText text; - private ProgressBar progressBar; + private SpriteIcon icon = null!; + private OsuSpriteText text = null!; + private ProgressBar progressBar = null!; public StateDisplay() { @@ -86,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants }; } - private OsuColour colours; + private OsuColour colours = null!; public void UpdateStatus(MultiplayerUserState state, BeatmapAvailability availability) { @@ -164,10 +161,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants break; case DownloadState.Downloading: - Debug.Assert(availability.DownloadProgress != null); - progressBar.FadeIn(fade_time); - progressBar.CurrentTime = availability.DownloadProgress.Value; + progressBar.CurrentTime = availability.DownloadProgress ?? 0; text.Text = "downloading map"; icon.Icon = FontAwesome.Solid.ArrowAltCircleDown; 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/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index c7af87a91d..4e9ab07e4c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.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; using osu.Framework.Timing; using osu.Game.Online.Multiplayer; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index a0558f97a9..6e939c3916 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -1,12 +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 JetBrains.Annotations; +using System.Threading; using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Timing; +using osu.Framework.Audio; using osu.Game.Beatmaps; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -18,60 +15,60 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public class MultiSpectatorPlayer : SpectatorPlayer { - private readonly Bindable waitingOnFrames = new Bindable(true); - private readonly ISpectatorPlayerClock spectatorPlayerClock; + /// + /// All adjustments applied to the clock of this which come from mods. + /// + public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods; + + private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); + private readonly SpectatorPlayerClock spectatorPlayerClock; /// /// Creates a new . /// /// The score containing the player's replay. /// The clock controlling the gameplay running state. - public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) + public MultiSpectatorPlayer(Score score, SpectatorPlayerClock spectatorPlayerClock) : base(score, new PlayerConfiguration { AllowUserInteraction = false }) { this.spectatorPlayerClock = spectatorPlayerClock; } [BackgroundDependencyLoader] - private void load() + private void load(CancellationToken cancellationToken) { - spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames); + // HUD overlay may not be loaded if load has been cancelled early. + if (cancellationToken.IsCancellationRequested) + return; HUDOverlay.PlayerSettingsOverlay.Expire(); HUDOverlay.HoldToQuit.Expire(); } + 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. + if (GameplayClockContainer.SourceClock.IsRunning) + GameplayClockContainer.Start(); + else + GameplayClockContainer.Stop(); + + base.Update(); + } + protected override void UpdateAfterChildren() { 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) - => new SpectatorGameplayClockContainer(spectatorPlayerClock); - - private class SpectatorGameplayClockContainer : GameplayClockContainer { - public SpectatorGameplayClockContainer([NotNull] IClock sourceClock) - : base(sourceClock) - { - } - - protected override void Update() - { - // The SourceClock here is always a CatchUpSpectatorPlayerClock. - // The player clock's running state is controlled externally, but the local pausing state needs to be updated to stop gameplay. - if (SourceClock.IsRunning) - Start(); - else - Stop(); - - base.Update(); - } - - protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source); + var gameplayClockContainer = new GameplayClockContainer(spectatorPlayerClock); + clockAdjustmentsFromMods.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods); + return gameplayClockContainer; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 8270bb3b6f..1fd04d35f8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -1,16 +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.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Bindables; +using osu.Framework.Audio; +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 +39,19 @@ 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 IAggregateAudioAdjustment? boundAdjustments; 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 +76,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.GameplayClock)); - syncManager.AddPlayerClock(instances[i].GameplayClock); - } + grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock())); LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users) { @@ -136,7 +135,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate }, _ => { foreach (var instance in instances) - leaderboard.AddClock(instance.UserId, instance.GameplayClock); + leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock); leaderboardFlow.Insert(0, leaderboard); @@ -162,59 +161,52 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate masterClockContainer.Reset(); - syncManager.ReadyToStart += onReadyToStart; - syncManager.MasterState.BindValueChanged(onMasterStateChanged, true); + // Start with adjustments from the first player to keep a sane state. + bindAudioAdjustments(instances.First()); } protected override void Update() { base.Update(); - if (!isCandidateAudioSource(currentAudioSource?.GameplayClock)) + if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock)) { - currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock)) - .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime)) + currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock)) + .OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime)) .FirstOrDefault(); + // Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio. + if (currentAudioSource != null) + bindAudioAdjustments(currentAudioSource); + foreach (var instance in instances) instance.Mute = instance != currentAudioSource; } } - private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) - => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; + private void bindAudioAdjustments(PlayerArea first) + { + if (boundAdjustments != null) + masterClockContainer.AdjustmentsFromMods.UnbindAdjustments(boundAdjustments); - private void onReadyToStart() + boundAdjustments = first.ClockAdjustmentsFromMods; + masterClockContainer.AdjustmentsFromMods.BindAdjustments(boundAdjustments); + } + + private bool isCandidateAudioSource(SpectatorPlayerClock? clock) + => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames; + + 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; - } + masterClockContainer.Reset(startTime, true); } protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState) @@ -224,19 +216,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) => instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score); - protected override void EndGameplay(int userId, SpectatorState state) + protected override void QuitGameplay(int userId) { - // Allowed passed/failed users to complete their remaining replay frames. - // The failed state isn't really possible in multiplayer (yet?) but is added here just for safety in case it starts being used. - if (state.State == SpectatedUserState.Passed || state.State == SpectatedUserState.Failed) - return; - RemoveUser(userId); var instance = instances.Single(i => i.UserId == userId); instance.FadeColour(colours.Gray4, 400, Easing.OutQuint); - syncManager.RemovePlayerClock(instance.GameplayClock); + syncManager.RemoveManagedClock(instance.SpectatorPlayerClock); } public override bool OnBackButton() @@ -250,7 +237,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 5bae4f9ea5..96f134568d 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,28 +38,33 @@ 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 SpectatorPlayerClock; + + /// + /// The clock adjustments applied by the loaded in this area. + /// + public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods; /// /// The currently-loaded score. /// - [CanBeNull] - public Score Score { get; private set; } + public Score? Score { get; private set; } [Resolved] - private BeatmapManager beatmapManager { get; set; } + private IBindable beatmap { get; set; } = null!; + private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); 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; + SpectatorPlayerClock = clock; RelativeSizeAxes = Axes.Both; Masking = true; @@ -80,18 +81,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate }; audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); - - GameplayClock.Source = masterClock; } - 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."); Score = score; - gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + gameplayContent.Child = new PlayerIsolationContainer(beatmap.Value, Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, Child = stack = new OsuScreenStack @@ -102,8 +101,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate stack.Push(new MultiSpectatorPlayerLoader(Score, () => { - var player = new MultiSpectatorPlayer(Score, GameplayClock); + var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock); player.OnGameplayStarted += () => OnGameplayStarted?.Invoke(); + + clockAdjustmentsFromMods.BindAdjustments(player.ClockAdjustmentsFromMods); + return player; })); 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..45615d4e19 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs @@ -0,0 +1,104 @@ +// 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) + { + // When in catch-up mode, the source is usually not running. + // In such a case, its elapsed time may be zero, which would cause catch-up to get stuck. + // To avoid this, use a constant 16ms elapsed time for now. Probably not too correct, but this whole logic isn't too correct anyway. + // Clamping is required to ensure that player clocks don't get too far ahead if ProcessFrame is run multiple times. + double elapsedSource = masterClock.ElapsedFrameTime != 0 ? masterClock.ElapsedFrameTime : Math.Clamp(masterClock.CurrentTime - CurrentTime, 0, 16); + 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/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index 0bf5f2604c..50ad3228e5 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -86,6 +86,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room))] protected Bindable AutoStartDuration { get; private set; } + [Resolved(typeof(Room))] + protected Bindable AutoSkip { get; private set; } + [Resolved(CanBeNull = true)] private IBindable subScreenSelectedItem { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 61ea7d68ee..7e5d90bd4f 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -105,7 +105,8 @@ namespace osu.Game.Screens.OnlinePlay while (this.IsCurrentScreen()) this.Exit(); } - else + // Also handle the case where a child screen is current (ie. gameplay). + else if (this.GetChildScreen() != null) { this.MakeCurrent(); Schedule(forcefullyExit); diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index f26480909e..ea20270c1e 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -1,13 +1,11 @@ // 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 Humanizer; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -35,32 +33,34 @@ namespace osu.Game.Screens.OnlinePlay public override bool AllowEditing => false; [Resolved(typeof(Room), nameof(Room.Playlist))] - protected BindableList Playlist { get; private set; } - - [CanBeNull] - [Resolved(CanBeNull = true)] - protected IBindable SelectedItem { get; private set; } + protected BindableList Playlist { get; private set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); private readonly Room room; - - private WorkingBeatmap initialBeatmap; - private RulesetInfo initialRuleset; - private IReadOnlyList initialMods; - private bool itemSelected; - + private readonly PlaylistItem? initialItem; private readonly FreeModSelectOverlay freeModSelectOverlay; - private IDisposable freeModSelectOverlayRegistration; - protected OnlinePlaySongSelect(Room room) + private IDisposable? freeModSelectOverlayRegistration; + + /// + /// Creates a new . + /// + /// The room. + /// An optional initial to use for the initial beatmap/ruleset/mods. + /// If null, the last in the room will be used. + protected OnlinePlaySongSelect(Room room, PlaylistItem? initialItem = null) { this.room = room; + this.initialItem = initialItem ?? room.Playlist.LastOrDefault(); Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; @@ -75,11 +75,6 @@ namespace osu.Game.Screens.OnlinePlay private void load() { LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; - - initialBeatmap = Beatmap.Value; - initialRuleset = Ruleset.Value; - initialMods = Mods.Value.ToList(); - LoadComponent(freeModSelectOverlay); } @@ -87,14 +82,35 @@ namespace osu.Game.Screens.OnlinePlay { base.LoadComplete(); - var rulesetInstance = SelectedItem?.Value?.RulesetID == null ? null : rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - - if (rulesetInstance != null) + if (initialItem != null) { - // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. - // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - FreeMods.Value = SelectedItem.Value.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + // Prefer using a local databased beatmap lookup since OnlineId may be -1 for an invalid beatmap selection. + BeatmapInfo? beatmapInfo = initialItem.Beatmap as BeatmapInfo; + + // And in the case that this isn't a local databased beatmap, query by online ID. + if (beatmapInfo == null) + { + int onlineId = initialItem.Beatmap.OnlineID; + beatmapInfo = beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId); + } + + if (beatmapInfo != null) + Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo); + + RulesetInfo? ruleset = rulesets.GetRuleset(initialItem.RulesetID); + + if (ruleset != null) + { + Ruleset.Value = ruleset; + + var rulesetInstance = ruleset.CreateInstance(); + Debug.Assert(rulesetInstance != null); + + // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. + // Similarly, freeMods is currently empty but should only contain the allowed mods. + Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + } } Mods.BindValueChanged(onModsChanged); @@ -125,13 +141,7 @@ namespace osu.Game.Screens.OnlinePlay AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() }; - if (SelectItem(item)) - { - itemSelected = true; - return true; - } - - return false; + return SelectItem(item); } /// @@ -154,15 +164,7 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(ScreenExitEvent e) { - if (!itemSelected) - { - Beatmap.Value = initialBeatmap; - Ruleset.Value = initialRuleset; - Mods.Value = initialMods; - } - freeModSelectOverlay.Hide(); - return base.OnExiting(e); } @@ -199,7 +201,6 @@ namespace osu.Game.Screens.OnlinePlay protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - freeModSelectOverlayRegistration?.Dispose(); } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 282c1db585..1c4d02bb11 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeFinalScore(ScoringMode.Standardised, Score.ScoreInfo)); + Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo)); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index 53b38962ac..27193d3cb6 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -180,31 +180,26 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The callback to invoke with the final s. /// The s that were retrieved from s. /// An optional pivot around which the scores were retrieved. - private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) + private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) => Schedule(() => { - var scoreInfos = scores.Select(s => s.CreateScoreInfo(rulesets, playlistItem, Beatmap.Value.BeatmapInfo)).ToArray(); + var scoreInfos = scoreManager.OrderByTotalScore(scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo))).ToArray(); - // Score panels calculate total score before displaying, which can take some time. In order to count that calculation as part of the loading spinner display duration, - // calculate the total scores locally before invoking the success callback. - scoreManager.OrderByTotalScoreAsync(scoreInfos).ContinueWith(_ => Schedule(() => + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) { - // Select a score if we don't already have one selected. - // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). - if (SelectedScore.Value == null) + Schedule(() => { - Schedule(() => - { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); - }); - } + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } - // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); - hideLoadingSpinners(pivot); - })); - } + hideLoadingSpinners(pivot); + }); private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 1e565e298e..cd52981528 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.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; using System.Collections.Specialized; using System.Linq; @@ -29,9 +27,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public class PlaylistsRoomSettingsOverlay : RoomSettingsOverlay { - public Action EditPlaylist; + public Action? EditPlaylist; - private MatchSettings settings; + private MatchSettings settings = null!; protected override OsuButton SubmitButton => settings.ApplyButton; @@ -55,28 +53,30 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { private const float disabled_alpha = 0.2f; - public Action EditPlaylist; + public Action? EditPlaylist; - public OsuTextBox NameField, MaxParticipantsField, MaxAttemptsField; - public OsuDropdown DurationField; - public RoomAvailabilityPicker AvailabilityPicker; - public TriangleButton ApplyButton; + public OsuTextBox NameField = null!, MaxParticipantsField = null!, MaxAttemptsField = null!; + public OsuDropdown DurationField = null!; + public RoomAvailabilityPicker AvailabilityPicker = null!; + public TriangleButton ApplyButton = null!; public bool IsLoading => loadingLayer.State.Value == Visibility.Visible; - public OsuSpriteText ErrorText; + public OsuSpriteText ErrorText = null!; - private LoadingLayer loadingLayer; - private DrawableRoomPlaylist playlist; - private OsuSpriteText playlistLength; + private LoadingLayer loadingLayer = null!; + private DrawableRoomPlaylist playlist = null!; + private OsuSpriteText playlistLength = null!; - private PurpleTriangleButton editPlaylistButton; - - [Resolved(CanBeNull = true)] - private IRoomManager manager { get; set; } + private PurpleTriangleButton editPlaylistButton = null!; [Resolved] - private IAPIProvider api { get; set; } + private IRoomManager? manager { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable localUser = null!; private readonly Room room; @@ -140,9 +140,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Section("Duration") { - Child = DurationField = new DurationDropdown + Child = new Container { RelativeSizeAxes = Axes.X, + Height = 40, + Child = DurationField = new DurationDropdown + { + RelativeSizeAxes = Axes.X + } } }, new Section("Allowed attempts (across all playlist items)") @@ -299,7 +304,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists MaxAttempts.BindValueChanged(count => MaxAttemptsField.Text = count.NewValue?.ToString(), true); Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true); - api.LocalUser.BindValueChanged(populateDurations, true); + localUser = api.LocalUser.GetBoundCopy(); + localUser.BindValueChanged(populateDurations, true); playlist.Items.BindTo(Playlist); Playlist.BindCollectionChanged(onPlaylistChanged, true); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 8a9c4db6ad..228ecd4bf3 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; @@ -75,151 +76,155 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new GridContainer + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Child = new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - // Playlist items column - new Container + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Child = new GridContainer + // Playlist items column + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedPlaylistHeader(), }, + new Drawable[] + { + new DrawableRoomPlaylist + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = Room.Playlist }, + SelectedItem = { BindTarget = SelectedItem }, + AllowSelection = true, + AllowShowingResults = true, + RequestResults = item => + { + Debug.Assert(RoomId.Value != null); + ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + } + }, + // Spacer + null, + // Middle column (mods and leaderboard) + new GridContainer { RelativeSizeAxes = Axes.Both, Content = new[] { - new Drawable[] { new OverlinedPlaylistHeader(), }, + new[] + { + UserModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Margin = new MarginPadding { Bottom = 10 }, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + } + } + }, + }, new Drawable[] { - new DrawableRoomPlaylist + progressSection = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Items = { BindTarget = Room.Playlist }, - SelectedItem = { BindTarget = SelectedItem }, - AllowSelection = true, - AllowShowingResults = true, - RequestResults = item => + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Margin = new MarginPadding { Bottom = 10 }, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Debug.Assert(RoomId.Value != null); - ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); + new OverlinedHeader("Progress"), + new RoomLocalUserInfo(), } - } + }, }, + new Drawable[] + { + new OverlinedHeader("Leaderboard") + }, + new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + }, + // Spacer + null, + // Main right column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Chat") }, + new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), new Dimension(), } - } - }, - // Spacer - null, - // Middle column (mods and leaderboard) - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Children = new Drawable[] - { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - } - } - }, - }, - new Drawable[] - { - progressSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OverlinedHeader("Progress"), - new RoomLocalUserInfo(), - } - }, - }, - new Drawable[] - { - new OverlinedHeader("Leaderboard") - }, - new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } }, }, - }, + } } }; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 73765fc661..e3f7b5dfc4 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.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 osu.Framework.Screens; using osu.Game.Online.API; diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 0678a90f71..6be13bbda3 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -40,11 +40,10 @@ namespace osu.Game.Screens public virtual bool AllowExternalScreenChange => false; - /// - /// Whether all overlays should be hidden when this screen is entered or resumed. - /// public virtual bool HideOverlaysOnEnter => false; + public virtual bool HideMenuCursorOnNonMouseInput => false; + /// /// The initial overlay activation mode to use when this screen is entered for the first time. /// diff --git a/osu.Game/Screens/Play/ComboEffects.cs b/osu.Game/Screens/Play/ComboEffects.cs index 77681401bb..442b061af7 100644 --- a/osu.Game/Screens/Play/ComboEffects.cs +++ b/osu.Game/Screens/Play/ComboEffects.cs @@ -45,7 +45,7 @@ namespace osu.Game.Screens.Play private ISamplePlaybackDisabler samplePlaybackDisabler { get; set; } [Resolved] - private GameplayClock gameplayClock { get; set; } + private IGameplayClock gameplayClock { get; set; } private void onComboChange(ValueChangedEvent combo) { diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index c1223d7262..a4b4bf4d2b 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -1,14 +1,11 @@ // 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.Audio; using osu.Framework.Bindables; using osu.Game.Rulesets.UI; using System; using System.Collections.Generic; -using JetBrains.Annotations; using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -34,27 +31,27 @@ namespace osu.Game.Screens.Play /// public class FailAnimation : Container { - public Action OnComplete; + public Action? OnComplete; private readonly DrawableRuleset drawableRuleset; private readonly BindableDouble trackFreq = new BindableDouble(1); private readonly BindableDouble volumeAdjustment = new BindableDouble(0.5); - private Container filters; + private Container filters = null!; - private Box redFlashLayer; + private Box redFlashLayer = null!; - private Track track; + private Track track = null!; - private AudioFilter failLowPassFilter; - private AudioFilter failHighPassFilter; + private AudioFilter failLowPassFilter = null!; + private AudioFilter failHighPassFilter = null!; private const float duration = 2500; - private Sample failSample; + private Sample? failSample; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; protected override Container Content { get; } = new Container { @@ -66,8 +63,7 @@ namespace osu.Game.Screens.Play /// /// The player screen background, used to adjust appearance on failing. /// - [CanBeNull] - public BackgroundScreen Background { private get; set; } + public BackgroundScreen? Background { private get; set; } public FailAnimation(DrawableRuleset drawableRuleset) { @@ -105,6 +101,7 @@ namespace osu.Game.Screens.Play } private bool started; + private bool filtersRemoved; /// /// Start the fail animation playing. @@ -113,6 +110,7 @@ namespace osu.Game.Screens.Play public void Start() { if (started) throw new InvalidOperationException("Animation cannot be started more than once."); + if (filtersRemoved) throw new InvalidOperationException("Animation cannot be started after filters have been removed."); started = true; @@ -125,7 +123,7 @@ namespace osu.Game.Screens.Play failHighPassFilter.CutoffTo(300); failLowPassFilter.CutoffTo(300, duration, Easing.OutCubic); - failSample.Play(); + failSample?.Play(); track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); @@ -155,16 +153,20 @@ namespace osu.Game.Screens.Play public void RemoveFilters(bool resetTrackFrequency = true) { - if (resetTrackFrequency) - track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + filtersRemoved = true; - track?.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); + if (!started) + return; + + if (resetTrackFrequency) + track.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + + track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); if (filters.Parent == null) return; - RemoveInternal(filters); - filters.Dispose(); + RemoveInternal(filters, true); } protected override void Update() diff --git a/osu.Game/Screens/Play/FailOverlay.cs b/osu.Game/Screens/Play/FailOverlay.cs index 1a31b0f462..2af5102369 100644 --- a/osu.Game/Screens/Play/FailOverlay.cs +++ b/osu.Game/Screens/Play/FailOverlay.cs @@ -3,14 +3,25 @@ #nullable disable +using System; +using System.Threading.Tasks; +using osu.Game.Scoring; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; namespace osu.Game.Screens.Play { public class FailOverlay : GameplayMenuOverlay { + public Func> SaveReplay; + public override string Header => "failed"; public override string Description => "you're dead, try again?"; @@ -19,6 +30,39 @@ namespace osu.Game.Screens.Play { AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + // from #10339 maybe this is a better visual effect + Add(new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = TwoLayerButton.SIZE_EXTENDED.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new SaveFailedScoreButton(SaveReplay) + { + Width = 300 + }, + } + } + } + }); } } } diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs deleted file mode 100644 index e6248014c5..0000000000 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ /dev/null @@ -1,79 +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 : IFrameBasedClock - { - internal readonly IFrameBasedClock UnderlyingClock; - - public readonly BindableBool IsPaused = new BindableBool(); - - /// - /// All adjustments applied to this clock which don't come from gameplay or mods. - /// - public virtual IEnumerable> NonGameplayAdjustments => Enumerable.Empty>(); - - public GameplayClock(IFrameBasedClock underlyingClock) - { - UnderlyingClock = underlyingClock; - } - - public double CurrentTime => UnderlyingClock.CurrentTime; - - public double Rate => UnderlyingClock.Rate; - - /// - /// The rate of gameplay when playback is at 100%. - /// This excludes any seeking / user adjustments. - /// - public double TrueGameplayRate - { - get - { - double baseRate = Rate; - - foreach (var adjustment in NonGameplayAdjustments) - { - if (Precision.AlmostEquals(adjustment.Value, 0)) - return 0; - - baseRate /= adjustment.Value; - } - - 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 9396b3311f..35b79fd628 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -1,99 +1,118 @@ // 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.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Timing; +using osu.Game.Beatmaps; 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. /// - [Cached] - public abstract class GameplayClockContainer : Container, IAdjustableClock + [Cached(typeof(IGameplayClock))] + public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock { - /// - /// The final clock which is exposed to gameplay components. - /// - public GameplayClock GameplayClock { get; private set; } - /// /// Whether gameplay is paused. /// - public readonly BindableBool IsPaused = new BindableBool(true); + public IBindable IsPaused => isPaused; /// - /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. + /// The source clock. Should generally not be used for any timekeeping purposes. /// - protected readonly DecoupleableInterpolatingFramedClock AdjustableSource; - - /// - /// The source clock. - /// - protected IClock SourceClock { get; private set; } + public IClock SourceClock { get; private set; } /// /// Invoked when a seek has been performed via /// - public event Action OnSeek; + public event Action? OnSeek; /// /// The time from which the clock should start. Will be seeked to on calling . + /// Can be adjusted by calling with a time value. /// /// - /// If not set, a value of zero will be used. - /// Importantly, the value will be inferred from the current ruleset in unless specified. + /// By default, a value of zero will be used. + /// Importantly, the value will be inferred from the current beatmap in by default. /// - public double? StartTime { get; set; } + public double StartTime { get; protected set; } + + public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments(); + + private readonly BindableBool isPaused = new BindableBool(true); + + /// + /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. + /// This is the final source exposed to gameplay components via delegation in this class. + /// + protected readonly FramedBeatmapClock GameplayClock; + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; /// /// Creates a new . /// /// The source used for timing. - protected GameplayClockContainer(IClock sourceClock) + /// Whether to apply platform, user and beatmap offsets to the mix. + public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false) { SourceClock = sourceClock; RelativeSizeAxes = Axes.Both; - AdjustableSource = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; - IsPaused.BindValueChanged(OnIsPausedChanged); - } - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - dependencies.CacheAs(GameplayClock = CreateGameplayClock(AdjustableSource)); - GameplayClock.IsPaused.BindTo(IsPaused); - - return dependencies; + InternalChildren = new Drawable[] + { + GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false }, + Content + }; } /// - /// Starts gameplay. + /// Starts gameplay and marks un-paused state. /// - public virtual void Start() + public void Start() { + if (!isPaused.Value) + return; + + isPaused.Value = false; + ensureSourceClockSet(); - if (!AdjustableSource.IsRunning) + PrepareStart(); + + // The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time. + // Because we generally update our own current time quicker than children can query it (via Start/Seek/Update), + // this means that the first frame ever exposed to children may have a non-zero current time. + // + // If the child component is not aware of the parent ElapsedFrameTime (which is the case for FrameStabilityContainer) + // they will take on the new CurrentTime with a zero elapsed time. This can in turn cause components to behave incorrectly + // if they are intending to trigger events at the precise StartTime (ie. DrawableStoryboardSample). + // + // By scheduling the start call, children are guaranteed to receive one frame at the original start time, allowing + // then to progress with a correct locally calculated elapsed time. + SchedulerAfterChildren.Add(() => { - // 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); + if (isPaused.Value) + return; - AdjustableSource.Start(); - } + StartGameplayClock(); + }); + } - IsPaused.Value = false; + /// + /// When is called, this will be run to give an opportunity to prepare the clock at the correct + /// start location. + /// + protected virtual void PrepareStart() + { } /// @@ -104,43 +123,56 @@ namespace osu.Game.Screens.Play { Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}"); - AdjustableSource.Seek(time); - - // Manually process to make sure the gameplay clock is correctly updated after a seek. - GameplayClock.UnderlyingClock.ProcessFrame(); + GameplayClock.Seek(time); OnSeek?.Invoke(); } /// - /// Stops gameplay. + /// Stops gameplay and marks paused state. /// - public void Stop() => IsPaused.Value = true; + public void Stop() + { + if (isPaused.Value) + return; + + isPaused.Value = true; + StopGameplayClock(); + } + + protected virtual void StartGameplayClock() => GameplayClock.Start(); + protected virtual void StopGameplayClock() => GameplayClock.Stop(); /// /// Resets this and the source to an initial state ready for gameplay. /// + /// The time to seek to on resetting. If null, the existing will be used. /// Whether to start the clock immediately, if not already started. - public void Reset(bool startClock = false) + public void Reset(double? time = null, bool startClock = false) { - // Manually stop the source in order to not affect the IsPaused state. - AdjustableSource.Stop(); + bool wasPaused = isPaused.Value; - if (!IsPaused.Value || startClock) - Start(); + Stop(); ensureSourceClockSet(); - Seek(StartTime ?? 0); + + if (time != null) + StartTime = time.Value; + + Seek(StartTime); + + if (!wasPaused || startClock) + Start(); } /// /// Changes the source clock. /// /// The new source. - protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock); + protected void ChangeSource(IClock sourceClock) => GameplayClock.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, @@ -148,40 +180,10 @@ namespace osu.Game.Screens.Play /// private void ensureSourceClockSet() { - if (AdjustableSource.Source == null) + if (GameplayClock.Source == null) ChangeSource(SourceClock); } - protected override void Update() - { - if (!IsPaused.Value) - GameplayClock.UnderlyingClock.ProcessFrame(); - - base.Update(); - } - - /// - /// 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(); - else - AdjustableSource.Start(); - } - - /// - /// 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 abstract GameplayClock CreateGameplayClock(IFrameBasedClock source); - #region IAdjustableClock bool IAdjustableClock.Seek(double position) @@ -192,7 +194,7 @@ namespace osu.Game.Screens.Play void IAdjustableClock.Reset() => Reset(); - public void ResetSpeedAdjustments() + public virtual void ResetSpeedAdjustments() { } @@ -202,12 +204,23 @@ namespace osu.Game.Screens.Play set => throw new NotSupportedException(); } - double IClock.Rate => GameplayClock.Rate; + public double Rate => GameplayClock.Rate; public double CurrentTime => GameplayClock.CurrentTime; public bool IsRunning => GameplayClock.IsRunning; #endregion + + public void ProcessFrame() + { + // Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times. + } + + public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime; + + public double FramesPerSecond => GameplayClock.FramesPerSecond; + + public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo; } } diff --git a/osu.Game/Screens/Play/GameplayClockExtensions.cs b/osu.Game/Screens/Play/GameplayClockExtensions.cs new file mode 100644 index 0000000000..5e88b41080 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayClockExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Screens.Play +{ + public static class GameplayClockExtensions + { + /// + /// The rate of gameplay when playback is at 100%. + /// This excludes any seeking / user adjustments. + /// + public static double GetTrueGameplayRate(this IGameplayClock clock) + { + // To handle rewind, we still want to maintain the same direction as the underlying clock. + double rate = clock.Rate == 0 ? 1 : Math.Sign(clock.Rate); + + return rate + * clock.AdjustmentsFromMods.AggregateFrequency.Value + * clock.AdjustmentsFromMods.AggregateTempo.Value; + } + } +} diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index 9fb62106f3..c2162d4df2 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -70,6 +70,7 @@ namespace osu.Game.Screens.Play { ScoreInfo = { + BeatmapInfo = beatmap.BeatmapInfo, Ruleset = ruleset.RulesetInfo } }; diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs new file mode 100644 index 0000000000..04774b974f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs @@ -0,0 +1,59 @@ +// 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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Screens.Play.HUD.ClicksPerSecond +{ + public class ClicksPerSecondCalculator : Component + { + private readonly List timestamps = new List(); + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [Resolved(canBeNull: true)] + private DrawableRuleset? drawableRuleset { get; set; } + + public int Value { get; private set; } + + // Even though `FrameStabilityContainer` caches as a `GameplayClock`, we need to check it directly via `drawableRuleset` + // as this calculator is not contained within the `FrameStabilityContainer` and won't see the dependency. + private IGameplayClock clock => drawableRuleset?.FrameStableClock ?? gameplayClock; + + public ClicksPerSecondCalculator() + { + RelativeSizeAxes = Axes.Both; + } + + public void AddInputTimestamp() => timestamps.Add(clock.CurrentTime); + + protected override void Update() + { + base.Update(); + + double latestValidTime = clock.CurrentTime; + double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate(); + + int count = 0; + + for (int i = timestamps.Count - 1; i >= 0; i--) + { + // handle rewinding by removing future timestamps as we go + if (timestamps[i] > latestValidTime) + { + timestamps.RemoveAt(i); + continue; + } + + if (timestamps[i] >= earliestTimeValid) + count++; + } + + Value = count; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs new file mode 100644 index 0000000000..243d8ed1e8 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs @@ -0,0 +1,102 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD.ClicksPerSecond +{ + public class ClicksPerSecondCounter : RollingCounter, ISkinnableDrawable + { + [Resolved] + private ClicksPerSecondCalculator calculator { get; set; } = null!; + + protected override double RollingDuration => 350; + + public bool UsesFixedAnchor { get; set; } + + public ClicksPerSecondCounter() + { + Current.Value = 0; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.BlueLighter; + } + + protected override void Update() + { + base.Update(); + + Current.Value = calculator.Value; + } + + protected override IHasText CreateText() => new TextComponent(); + + private class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 16, fixedWidth: true) + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 6, fixedWidth: false), + Text = @"clicks", + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 6, fixedWidth: false), + Text = @"/sec", + Padding = new MarginPadding { Bottom = 3f }, // align baseline better + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs new file mode 100644 index 0000000000..4179d41646 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ComboCounter.cs @@ -0,0 +1,24 @@ +// 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.Graphics.UserInterface; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract class ComboCounter : RollingCounter, ISkinnableDrawable + { + public bool UsesFixedAnchor { get; set; } + + protected ComboCounter() + { + Current.Value = DisplayedCount = 0; + } + + protected override double GetProportionalDuration(int currentValue, int newValue) + { + return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 1f14811169..0c9c363280 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -1,29 +1,17 @@ // 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.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, ISkinnableDrawable + public class DefaultComboCounter : ComboCounter { - public bool UsesFixedAnchor { get; set; } - - public DefaultComboCounter() - { - Current.Value = DisplayedCount = 0; - } - [BackgroundDependencyLoader] private void load(OsuColour colours, ScoreProcessor scoreProcessor) { @@ -31,17 +19,12 @@ namespace osu.Game.Screens.Play.HUD Current.BindTo(scoreProcessor.Combo); } + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); + protected override LocalisableString FormatCount(int count) { return $@"{count}x"; } - - protected override double GetProportionalDuration(int currentValue, int newValue) - { - return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; - } - - protected override OsuSpriteText CreateSpriteText() - => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); } } diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs similarity index 61% rename from osu.Game/Screens/Play/SongProgress.cs rename to osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index d1510d10c2..30b420441c 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -1,16 +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 System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Timing; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Rulesets.Objects; @@ -18,13 +12,10 @@ using osu.Game.Rulesets.UI; using osu.Game.Skinning; using osuTK; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public class SongProgress : OverlayContainer, ISkinnableDrawable + public class DefaultSongProgress : SongProgress { - public const float MAX_HEIGHT = info_height + bottom_bar_height + graph_height + handle_height; - - private const float info_height = 20; private const float bottom_bar_height = 5; private const float graph_height = SquareGraph.Column.WIDTH * 6; private const float handle_height = 18; @@ -37,8 +28,6 @@ namespace osu.Game.Screens.Play private readonly SongProgressGraph graph; private readonly SongProgressInfo info; - public Action RequestSeek; - /// /// Whether seeking is allowed and the progress bar should be shown. /// @@ -50,43 +39,19 @@ namespace osu.Game.Screens.Play public override bool HandleNonPositionalInput => AllowSeeking.Value; public override bool HandlePositionalInput => AllowSeeking.Value; - protected override bool BlockScrollInput => false; - - private double firstHitTime => objects.First().StartTime; - - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - private double lastHitTime => objects.Last().GetEndTime() + 1; - - private IEnumerable objects; - - public IEnumerable Objects - { - set - { - graph.Objects = objects = value; - - info.StartTime = firstHitTime; - info.EndTime = lastHitTime; - - bar.StartTime = firstHitTime; - bar.EndTime = lastHitTime; - } - } - - [Resolved(canBeNull: true)] - private Player player { get; set; } + [Resolved] + private Player? player { get; set; } [Resolved] - private GameplayClock gameplayClock { get; set; } + private DrawableRuleset? drawableRuleset { get; set; } - [Resolved(canBeNull: true)] - private DrawableRuleset drawableRuleset { get; set; } + [Resolved] + private OsuConfigManager config { get; set; } = null!; - private IClock referenceClock; + [Resolved] + private SkinManager skinManager { get; set; } = null!; - public bool UsesFixedAnchor { get; set; } - - public SongProgress() + public DefaultSongProgress() { RelativeSizeAxes = Axes.X; Anchor = Anchor.BottomRight; @@ -99,7 +64,6 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - Height = info_height, }, graph = new SongProgressGraph { @@ -127,9 +91,6 @@ namespace osu.Game.Screens.Play { if (player?.Configuration.AllowUserInteraction == true) ((IBindable)AllowSeeking).BindTo(drawableRuleset.HasReplayLoaded); - - referenceClock = drawableRuleset.FrameStableClock; - Objects = drawableRuleset.Objects; } graph.FillColour = bar.FillColour = colours.BlueLighter; @@ -137,20 +98,12 @@ namespace osu.Game.Screens.Play protected override void LoadComplete() { - Show(); - AllowSeeking.BindValueChanged(_ => updateBarVisibility(), true); ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); migrateSettingFromConfig(); } - [Resolved] - private OsuConfigManager config { get; set; } - - [Resolved] - private SkinManager skinManager { get; set; } - /// /// This setting has been migrated to a per-component level. /// Only take the value from the config if it is in a non-default state (then reset it to default so it only applies once). @@ -166,29 +119,26 @@ namespace osu.Game.Screens.Play ShowGraph.Value = configShowGraph.Value; // This is pretty ugly, but the only way to make this stick... - if (skinManager != null) + var skinnableTarget = this.FindClosestParent(); + + if (skinnableTarget != null) { - var skinnableTarget = this.FindClosestParent(); + // If the skin is not mutable, a mutable instance will be created, causing this migration logic to run again on the correct skin. + // Therefore we want to avoid resetting the config value on this invocation. + if (skinManager.EnsureMutableSkin()) + return; - if (skinnableTarget != null) + // If `EnsureMutableSkin` actually changed the skin, default layout may take a frame to apply. + // See `SkinnableTargetComponentsContainer`'s use of ScheduleAfterChildren. + ScheduleAfterChildren(() => { - // If the skin is not mutable, a mutable instance will be created, causing this migration logic to run again on the correct skin. - // Therefore we want to avoid resetting the config value on this invocation. - if (skinManager.EnsureMutableSkin()) - return; + var skin = skinManager.CurrentSkin.Value; + skin.UpdateDrawableTarget(skinnableTarget); - // If `EnsureMutableSkin` actually changed the skin, default layout may take a frame to apply. - // See `SkinnableTargetComponentsContainer`'s use of ScheduleAfterChildren. - ScheduleAfterChildren(() => - { - var skin = skinManager.CurrentSkin.Value; - skin.UpdateDrawableTarget(skinnableTarget); + skinManager.Save(skin); + }); - skinManager.Save(skin); - }); - - configShowGraph.SetDefault(); - } + configShowGraph.SetDefault(); } } } @@ -203,22 +153,30 @@ namespace osu.Game.Screens.Play this.FadeOut(100); } + protected override void UpdateObjects(IEnumerable objects) + { + graph.Objects = objects; + + info.StartTime = FirstHitTime; + info.EndTime = LastHitTime; + bar.StartTime = FirstHitTime; + bar.EndTime = LastHitTime; + } + + protected override void UpdateProgress(double progress, bool isIntro) + { + bar.CurrentTime = GameplayClock.CurrentTime; + + if (isIntro) + graph.Progress = 0; + else + graph.Progress = (int)(graph.ColumnCount * progress); + } + protected override void Update() { base.Update(); - - if (objects == null) - return; - - double gameplayTime = gameplayClock?.CurrentTime ?? Time.Current; - double frameStableTime = referenceClock?.CurrentTime ?? gameplayTime; - - double progress = Math.Min(1, (frameStableTime - firstHitTime) / (lastHitTime - firstHitTime)); - - bar.CurrentTime = gameplayTime; - graph.Progress = (int)(graph.ColumnCount * progress); - - Height = bottom_bar_height + graph_height + handle_size.Y + info_height - graph.Y; + Height = bottom_bar_height + graph_height + handle_size.Y + info.Height - graph.Y; } private void updateBarVisibility() diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index f21ce5e36a..48908fb9a0 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -1,11 +1,8 @@ // 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.Bindables; using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; @@ -13,15 +10,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public class GameplayLeaderboard : CompositeDrawable + public abstract class GameplayLeaderboard : CompositeDrawable { - private readonly int maxPanels; private readonly Cached sorting = new Cached(); public Bindable Expanded = new Bindable(); @@ -31,22 +27,22 @@ namespace osu.Game.Screens.Play.HUD private bool requiresScroll; private readonly OsuScrollContainer scroll; - private GameplayLeaderboardScore trackedScore; + public GameplayLeaderboardScore? TrackedScore { get; private set; } + + private const int max_panels = 8; /// /// Create a new leaderboard. /// - /// The maximum panels to show at once. Defines the maximum height of this component. - public GameplayLeaderboard(int maxPanels = 8) + protected GameplayLeaderboard() { - this.maxPanels = maxPanels; - Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH; InternalChildren = new Drawable[] { scroll = new InputDisabledScrollContainer { + ClampExtension = 0, RelativeSizeAxes = Axes.Both, Child = Flow = new FillFlowContainer { @@ -77,24 +73,25 @@ namespace osu.Game.Screens.Play.HUD /// Whether the player should be tracked on the leaderboard. /// Set to true for the local player or a player whose replay is currently being played. /// - public ILeaderboardScore Add([CanBeNull] APIUser user, bool isTracked) + public ILeaderboardScore Add(IUser? user, bool isTracked) { var drawable = CreateLeaderboardScoreDrawable(user, isTracked); if (isTracked) { - if (trackedScore != null) + if (TrackedScore != null) throw new InvalidOperationException("Cannot track more than one score."); - trackedScore = drawable; + TrackedScore = drawable; } drawable.Expanded.BindTo(Expanded); Flow.Add(drawable); drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true); + drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true); - int displayCount = Math.Min(Flow.Count, maxPanels); + int displayCount = Math.Min(Flow.Count, max_panels); Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y); requiresScroll = displayCount != Flow.Count; @@ -104,21 +101,22 @@ namespace osu.Game.Screens.Play.HUD public void Clear() { Flow.Clear(); - trackedScore = null; + TrackedScore = null; scroll.ScrollToStart(false); } - protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) => + protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) => new GameplayLeaderboardScore(user, isTracked); protected override void Update() { base.Update(); - if (requiresScroll && trackedScore != null) + if (requiresScroll && TrackedScore != null) { - float scrollTarget = scroll.GetChildPosInContent(trackedScore) + trackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; - scroll.ScrollTo(scrollTarget, false); + float scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; + + scroll.ScrollTo(scrollTarget); } const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT; @@ -126,7 +124,7 @@ namespace osu.Game.Screens.Play.HUD float fadeBottom = scroll.Current + scroll.DrawHeight; float fadeTop = scroll.Current + panel_height; - if (scroll.Current <= 0) fadeTop -= panel_height; + if (scroll.IsScrolledToStart()) fadeTop -= panel_height; if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; // logic is mostly shared with Leaderboard, copied here for simplicity. @@ -165,7 +163,10 @@ namespace osu.Game.Screens.Play.HUD if (sorting.IsValid) return; - var orderedByScore = Flow.OrderByDescending(i => i.TotalScore.Value).ToList(); + var orderedByScore = Flow + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.DisplayOrder.Value) + .ToList(); for (int i = 0; i < Flow.Count; i++) { diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 0f007cd1cb..2eec8253b3 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -12,7 +13,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; using osuTK; @@ -39,8 +40,6 @@ namespace osu.Game.Screens.Play.HUD private const float rank_text_width = 35f; - private const float score_components_width = 85f; - private const float avatar_size = 25f; private const double panel_transition_duration = 500; @@ -55,6 +54,7 @@ namespace osu.Game.Screens.Play.HUD public BindableDouble Accuracy { get; } = new BindableDouble(1); public BindableInt Combo { get; } = new BindableInt(); public BindableBool HasQuit { get; } = new BindableBool(); + public Bindable DisplayOrder { get; } = new Bindable(); public Color4? BackgroundColour { get; set; } @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Play.HUD } [CanBeNull] - public APIUser User { get; } + public IUser User { get; } /// /// Whether this score is the local user or a replay player (and should be focused / always visible). @@ -103,7 +103,7 @@ namespace osu.Game.Screens.Play.HUD /// /// The score's player. /// Whether the player is the local user or a replay player. - public GameplayLeaderboardScore([CanBeNull] APIUser user, bool tracked) + public GameplayLeaderboardScore([CanBeNull] IUser user, bool tracked) { User = user; Tracked = tracked; @@ -160,7 +160,7 @@ namespace osu.Game.Screens.Play.HUD { new Dimension(GridSizeMode.Absolute, rank_text_width), new Dimension(), - new Dimension(GridSizeMode.AutoSize, maxSize: score_components_width), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { @@ -285,8 +285,19 @@ namespace osu.Game.Screens.Play.HUD LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); TotalScore.BindValueChanged(v => scoreText.Text = v.NewValue.ToString("N0"), true); - Accuracy.BindValueChanged(v => accuracyText.Text = v.NewValue.FormatAccuracy(), true); - Combo.BindValueChanged(v => comboText.Text = $"{v.NewValue}x", true); + + Accuracy.BindValueChanged(v => + { + accuracyText.Text = v.NewValue.FormatAccuracy(); + updateDetailsWidth(); + }, true); + + Combo.BindValueChanged(v => + { + comboText.Text = $"{v.NewValue}x"; + updateDetailsWidth(); + }, true); + HasQuit.BindValueChanged(_ => updateState()); } @@ -302,13 +313,10 @@ namespace osu.Game.Screens.Play.HUD private void changeExpandedState(ValueChangedEvent expanded) { - scoreComponents.ClearTransforms(); - if (expanded.NewValue) { gridContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutQuint); - scoreComponents.ResizeWidthTo(score_components_width, panel_transition_duration, Easing.OutQuint); scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint); usernameText.FadeIn(panel_transition_duration, Easing.OutQuint); @@ -317,11 +325,29 @@ namespace osu.Game.Screens.Play.HUD { gridContainer.ResizeWidthTo(compact_width, panel_transition_duration, Easing.OutQuint); - scoreComponents.ResizeWidthTo(0, panel_transition_duration, Easing.OutQuint); scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint); usernameText.FadeOut(text_transition_duration, Easing.OutQuint); } + + updateDetailsWidth(); + } + + private float? scoreComponentsTargetWidth; + + private void updateDetailsWidth() + { + const float score_components_min_width = 88f; + + float newWidth = Expanded.Value + ? Math.Max(score_components_min_width, comboText.DrawWidth + accuracyText.DrawWidth + 25) + : 0; + + if (scoreComponentsTargetWidth == newWidth) + return; + + scoreComponentsTargetWidth = newWidth; + scoreComponents.ResizeWidthTo(newWidth, panel_transition_duration, Easing.OutQuint); } private void updateState() diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 747f4d4a8a..d6b9c62369 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -44,8 +45,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons); private SpriteIcon arrow; - private Drawable labelEarly; - private Drawable labelLate; + private UprightAspectMaintainingContainer labelEarly; + private UprightAspectMaintainingContainer labelLate; private Container colourBarsEarly; private Container colourBarsLate; @@ -122,6 +123,20 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters RelativeSizeAxes = Axes.Y, Width = judgement_line_width, }, + labelEarly = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -10, + }, + labelLate = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Y = 10, + }, } }, arrowContainer = new Container @@ -261,57 +276,39 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { const float icon_size = 14; - labelEarly?.Expire(); - labelEarly = null; - - labelLate?.Expire(); - labelLate = null; - switch (style) { case LabelStyles.None: break; case LabelStyles.Icons: - labelEarly = new SpriteIcon + labelEarly.Child = new SpriteIcon { - Y = -10, Size = new Vector2(icon_size), Icon = FontAwesome.Solid.ShippingFast, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, }; - labelLate = new SpriteIcon + labelLate.Child = new SpriteIcon { - Y = 10, Size = new Vector2(icon_size), Icon = FontAwesome.Solid.Bicycle, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, }; break; case LabelStyles.Text: - labelEarly = new OsuSpriteText + labelEarly.Child = new OsuSpriteText { - Y = -10, Text = "Early", Font = OsuFont.Default.With(size: 10), Height = 12, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, }; - labelLate = new OsuSpriteText + labelLate.Child = new OsuSpriteText { - Y = 10, Text = "Late", Font = OsuFont.Default.With(size: 10), Height = 12, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, }; break; @@ -320,26 +317,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters throw new ArgumentOutOfRangeException(nameof(style), style, null); } - if (labelEarly != null) - { - colourBars.Add(labelEarly); - labelEarly.FadeInFromZero(500); - } - - if (labelLate != null) - { - colourBars.Add(labelLate); - labelLate.FadeInFromZero(500); - } - } - - protected override void Update() - { - base.Update(); - - // undo any layout rotation to display icons in the correct orientation - if (labelEarly != null) labelEarly.Rotation = -Rotation; - if (labelLate != null) labelLate.Rotation = -Rotation; + labelEarly.FadeInFromZero(500); + labelLate.FadeInFromZero(500); } private void createColourBars((HitResult result, double length)[] windows) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index 335d956e39..dadec7c06b 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -1,13 +1,13 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osuTK; @@ -17,18 +17,37 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { public class ColourHitErrorMeter : HitErrorMeter { - internal const int MAX_DISPLAYED_JUDGEMENTS = 20; - private const int animation_duration = 200; private const int drawable_judgement_size = 8; - private const int spacing = 2; + + [SettingSource("Judgement count", "The number of displayed judgements")] + public BindableNumber JudgementCount { get; } = new BindableNumber(20) + { + MinValue = 1, + MaxValue = 50, + }; + + [SettingSource("Judgement spacing", "The space between each displayed judgement")] + public BindableNumber JudgementSpacing { get; } = new BindableNumber(2) + { + MinValue = 0, + MaxValue = 10, + }; + + [SettingSource("Judgement shape", "The shape of each displayed judgement")] + public Bindable JudgementShape { get; } = new Bindable(); private readonly JudgementFlow judgementsFlow; public ColourHitErrorMeter() { AutoSizeAxes = Axes.Both; - InternalChild = judgementsFlow = new JudgementFlow(); + InternalChild = judgementsFlow = new JudgementFlow + { + JudgementShape = { BindTarget = JudgementShape }, + JudgementSpacing = { BindTarget = JudgementSpacing }, + JudgementCount = { BindTarget = JudgementCount } + }; } protected override void OnNewJudgement(JudgementResult judgement) @@ -41,53 +60,105 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters public override void Clear() => judgementsFlow.Clear(); - private class JudgementFlow : FillFlowContainer + private class JudgementFlow : FillFlowContainer { public override IEnumerable FlowingChildren => base.FlowingChildren.Reverse(); + public readonly Bindable JudgementShape = new Bindable(); + + public readonly Bindable JudgementSpacing = new Bindable(); + + public readonly Bindable JudgementCount = new Bindable(); + public JudgementFlow() { - AutoSizeAxes = Axes.X; - Height = MAX_DISPLAYED_JUDGEMENTS * (drawable_judgement_size + spacing) - spacing; - Spacing = new Vector2(0, spacing); + Width = drawable_judgement_size; Direction = FillDirection.Vertical; LayoutDuration = animation_duration; LayoutEasing = Easing.OutQuint; } - public void Push(Color4 colour) - { - Add(new HitErrorCircle(colour, drawable_judgement_size)); - - if (Children.Count > MAX_DISPLAYED_JUDGEMENTS) - Children.FirstOrDefault(c => !c.IsRemoved)?.Remove(); - } - } - - internal class HitErrorCircle : Container - { - public bool IsRemoved { get; private set; } - - private readonly Circle circle; - - public HitErrorCircle(Color4 colour, int size) - { - Size = new Vector2(size); - Child = circle = new Circle - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = colour - }; - } - protected override void LoadComplete() { base.LoadComplete(); - circle.FadeInFromZero(animation_duration, Easing.OutQuint); - circle.MoveToY(-DrawSize.Y); - circle.MoveToY(0, animation_duration, Easing.OutQuint); + JudgementCount.BindValueChanged(count => + { + removeExtraJudgements(); + updateMetrics(); + }); + + JudgementSpacing.BindValueChanged(_ => updateMetrics(), true); + } + + public void Push(Color4 colour) + { + Add(new HitErrorShape(colour, drawable_judgement_size) + { + Shape = { BindTarget = JudgementShape }, + }); + + removeExtraJudgements(); + } + + private void removeExtraJudgements() + { + var remainingChildren = Children.Where(c => !c.IsRemoved); + + while (remainingChildren.Count() > JudgementCount.Value) + remainingChildren.First().Remove(); + } + + private void updateMetrics() + { + Height = JudgementCount.Value * (drawable_judgement_size + JudgementSpacing.Value) - JudgementSpacing.Value; + Spacing = new Vector2(0, JudgementSpacing.Value); + } + } + + public class HitErrorShape : Container + { + public bool IsRemoved { get; private set; } + + public readonly Bindable Shape = new Bindable(); + + private readonly Color4 colour; + + private Container content = null!; + + public HitErrorShape(Color4 colour, int size) + { + this.colour = colour; + Size = new Vector2(size); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Child = content = new Container + { + RelativeSizeAxes = Axes.Both, + Colour = colour + }; + + Shape.BindValueChanged(shape => + { + switch (shape.NewValue) + { + case ShapeStyle.Circle: + content.Child = new Circle { RelativeSizeAxes = Axes.Both }; + break; + + case ShapeStyle.Square: + content.Child = new Box { RelativeSizeAxes = Axes.Both }; + break; + } + }, true); + + content.FadeInFromZero(animation_duration, Easing.OutQuint); + content.MoveToY(-DrawSize.Y); + content.MoveToY(0, animation_duration, Easing.OutQuint); } public void Remove() @@ -97,5 +168,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters this.FadeOut(animation_duration, Easing.OutQuint).Expire(); } } + + public enum ShapeStyle + { + Circle, + Square + } } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index 26befd659c..dda17c25e6 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -59,30 +59,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters protected Color4 GetColourForHitResult(HitResult result) { - switch (result) - { - case HitResult.SmallTickMiss: - case HitResult.LargeTickMiss: - case HitResult.Miss: - return colours.Red; - - case HitResult.Meh: - return colours.Yellow; - - case HitResult.Ok: - return colours.Green; - - case HitResult.Good: - return colours.GreenLight; - - case HitResult.SmallTickHit: - case HitResult.LargeTickHit: - case HitResult.Great: - return colours.Blue; - - default: - return colours.BlueLight; - } + return colours.ForHitResult(result); } /// diff --git a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs index 20bf7045b8..aa06bb08a5 100644 --- a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs @@ -14,5 +14,11 @@ namespace osu.Game.Screens.Play.HUD BindableInt Combo { get; } BindableBool HasQuit { get; } + + /// + /// An optional value to guarantee stable ordering. + /// Lower numbers will appear higher in cases of ties. + /// + Bindable DisplayOrder { get; } } } diff --git a/osu.Game/Screens/Play/HUD/LongestComboCounter.cs b/osu.Game/Screens/Play/HUD/LongestComboCounter.cs new file mode 100644 index 0000000000..0e7af69af2 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/LongestComboCounter.cs @@ -0,0 +1,83 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class LongestComboCounter : ComboCounter + { + [BackgroundDependencyLoader] + private void load(OsuColour colours, ScoreProcessor scoreProcessor) + { + Colour = colours.YellowLighter; + Current.BindTo(scoreProcessor.HighestCombo); + } + + protected override IHasText CreateText() => new TextComponent(); + + private class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = $"{value}x"; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 20) + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"longest", + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"combo", + Padding = new MarginPadding { Bottom = 3f } + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index ac58325060..4201b3f4c9 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.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; using System.Collections.Generic; using System.Collections.Specialized; @@ -12,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; @@ -21,6 +20,7 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; +using osu.Game.Users; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD @@ -33,19 +33,20 @@ namespace osu.Game.Screens.Play.HUD public readonly SortedDictionary TeamScores = new SortedDictionary(); [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private SpectatorClient spectatorClient { get; set; } + private SpectatorClient spectatorClient { get; set; } = null!; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; [Resolved] - private UserLookupCache userLookupCache { get; set; } + private UserLookupCache userLookupCache { get; set; } = null!; + + private Bindable scoringMode = null!; private readonly MultiplayerRoomUser[] playingUsers; - private Bindable scoringMode; private readonly IBindableList playingUserIds = new BindableList(); @@ -125,14 +126,17 @@ namespace osu.Game.Screens.Play.HUD playingUserIds.BindCollectionChanged(playingUsersChanged); } - protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) + protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) { var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); - if (UserScores[user.Id].Team is int team) + if (user != null) { - leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); - leaderboardScore.TextColour = Color4.White; + if (UserScores[user.OnlineID].Team is int team) + { + leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); + leaderboardScore.TextColour = Color4.White; + } } return leaderboardScore; @@ -188,7 +192,7 @@ namespace osu.Game.Screens.Play.HUD { base.Dispose(isDisposing); - if (spectatorClient != null) + if (spectatorClient.IsNotNull()) { foreach (var user in playingUsers) spectatorClient.StopWatchingUser(user.UserID); diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index f045ab661e..6d63776dbb 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Humanizer; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -71,7 +70,7 @@ namespace osu.Game.Screens.Play.HUD var bindable = (IBindable)property.GetValue(component); if (!bindable.IsDefault) - Settings.Add(property.Name.Underscore(), bindable.GetUnderlyingSettingValue()); + Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue()); } if (component is Container container) @@ -99,5 +98,14 @@ namespace osu.Game.Screens.Play.HUD return Drawable.Empty(); } } + + public static Type[] GetAllAvailableDrawables() + { + return typeof(OsuGame).Assembly.GetTypes() + .Where(t => !t.IsInterface && !t.IsAbstract) + .Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)) + .OrderBy(t => t.Name) + .ToArray(); + } } } diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs new file mode 100644 index 0000000000..ab3cf2950c --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs @@ -0,0 +1,99 @@ +// 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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Screens.Play.HUD +{ + public class SoloGameplayLeaderboard : GameplayLeaderboard + { + private const int duration = 100; + + private readonly Bindable configVisibility = new Bindable(); + private readonly IUser trackingUser; + + public readonly IBindableList Scores = new BindableList(); + + // hold references to ensure bindables are updated. + private readonly List> scoreBindables = new List>(); + + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + /// + /// Whether the leaderboard should be visible regardless of the configuration value. + /// This is true by default, but can be changed. + /// + public readonly Bindable AlwaysVisible = new Bindable(true); + + public SoloGameplayLeaderboard(IUser trackingUser) + { + this.trackingUser = trackingUser; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true); + + // Alpha will be updated via `updateVisibility` below. + Alpha = 0; + + AlwaysVisible.BindValueChanged(_ => updateVisibility()); + configVisibility.BindValueChanged(_ => updateVisibility(), true); + } + + private void showScores() + { + Clear(); + scoreBindables.Clear(); + + if (!Scores.Any()) + return; + + foreach (var s in Scores) + { + var score = Add(s.User, false); + + var bindableTotal = scoreManager.GetBindableTotalScore(s); + + // Direct binding not possible due to differing types (see https://github.com/ppy/osu/issues/20298). + bindableTotal.BindValueChanged(total => score.TotalScore.Value = total.NewValue, true); + scoreBindables.Add(bindableTotal); + + score.Accuracy.Value = s.Accuracy; + score.Combo.Value = s.MaxCombo; + score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds(); + } + + ILeaderboardScore local = Add(trackingUser, true); + + local.TotalScore.BindTarget = scoreProcessor.TotalScore; + local.Accuracy.BindTarget = scoreProcessor.Accuracy; + local.Combo.BindTarget = scoreProcessor.HighestCombo; + + // Local score should always show lower than any existing scores in cases of ties. + local.DisplayOrder.Value = long.MaxValue; + } + + private void updateVisibility() => + this.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration); + } +} diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs new file mode 100644 index 0000000000..0b6494bd8a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SongProgress.cs @@ -0,0 +1,104 @@ +// 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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract class SongProgress : OverlayContainer, ISkinnableDrawable + { + // Some implementations of this element allow seeking during gameplay playback. + // Set a sane default of never handling input to override the behaviour provided by OverlayContainer. + public override bool HandleNonPositionalInput => false; + public override bool HandlePositionalInput => false; + protected override bool BlockScrollInput => false; + + public bool UsesFixedAnchor { get; set; } + + [Resolved] + protected IGameplayClock GameplayClock { get; private set; } = null!; + + [Resolved(canBeNull: true)] + private DrawableRuleset? drawableRuleset { get; set; } + + private IClock? referenceClock; + private IEnumerable? objects; + + public IEnumerable Objects + { + set + { + objects = value; + FirstHitTime = objects.FirstOrDefault()?.StartTime ?? 0; + //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). + LastHitTime = objects.LastOrDefault()?.GetEndTime() ?? 0; + UpdateObjects(objects); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Show(); + } + + protected double FirstHitTime { get; private set; } + + protected double LastHitTime { get; private set; } + + protected abstract void UpdateProgress(double progress, bool isIntro); + protected virtual void UpdateObjects(IEnumerable objects) { } + + [BackgroundDependencyLoader] + private void load() + { + if (drawableRuleset != null) + { + Objects = drawableRuleset.Objects; + referenceClock = drawableRuleset.FrameStableClock; + } + } + + protected override void Update() + { + base.Update(); + + if (objects == null) + return; + + // The reference clock is used to accurately tell the playfield's time. This is obtained from the drawable ruleset. + // However, if no drawable ruleset is available (i.e. used in tests), we fall back to the gameplay clock. + double currentTime = referenceClock?.CurrentTime ?? GameplayClock.CurrentTime; + + bool isInIntro = currentTime < FirstHitTime; + + if (isInIntro) + { + double introStartTime = GameplayClock.StartTime; + + double introOffsetCurrent = currentTime - introStartTime; + double introDuration = FirstHitTime - introStartTime; + + UpdateProgress(introOffsetCurrent / introDuration, true); + } + else + { + double objectOffsetCurrent = currentTime - FirstHitTime; + + double objectDuration = LastHitTime - FirstHitTime; + if (objectDuration == 0) + UpdateProgress(0, false); + else + UpdateProgress(objectOffsetCurrent / objectDuration, false); + } + } + } +} diff --git a/osu.Game/Screens/Play/SongProgressBar.cs b/osu.Game/Screens/Play/HUD/SongProgressBar.cs similarity index 99% rename from osu.Game/Screens/Play/SongProgressBar.cs rename to osu.Game/Screens/Play/HUD/SongProgressBar.cs index 67923f4b6a..db4e200724 100644 --- a/osu.Game/Screens/Play/SongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/SongProgressBar.cs @@ -13,7 +13,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Framework.Threading; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { public class SongProgressBar : SliderBar { diff --git a/osu.Game/Screens/Play/SongProgressGraph.cs b/osu.Game/Screens/Play/HUD/SongProgressGraph.cs similarity index 97% rename from osu.Game/Screens/Play/SongProgressGraph.cs rename to osu.Game/Screens/Play/HUD/SongProgressGraph.cs index c742df67ce..f234b45922 100644 --- a/osu.Game/Screens/Play/SongProgressGraph.cs +++ b/osu.Game/Screens/Play/HUD/SongProgressGraph.cs @@ -8,7 +8,7 @@ using System.Collections.Generic; using System.Diagnostics; using osu.Game.Rulesets.Objects; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { public class SongProgressGraph : SquareGraph { diff --git a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs new file mode 100644 index 0000000000..d0eb8f8ca1 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs @@ -0,0 +1,146 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using System; + +namespace osu.Game.Screens.Play.HUD +{ + public class SongProgressInfo : Container + { + private SizePreservingSpriteText timeCurrent; + private SizePreservingSpriteText timeLeft; + private SizePreservingSpriteText progress; + + private double startTime; + private double endTime; + + private int? previousPercent; + private int? previousSecond; + + private double songLength => endTime - startTime; + + private const int margin = 10; + + public double StartTime + { + set => startTime = value; + } + + public double EndTime + { + set => endTime = value; + } + + private IGameplayClock gameplayClock; + + [BackgroundDependencyLoader(true)] + private void load(OsuColour colours, IGameplayClock clock) + { + if (clock != null) + gameplayClock = clock; + + AutoSizeAxes = Axes.Y; + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = timeCurrent = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + }, + new Container + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = progress = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + }, + new Container + { + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = timeLeft = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + } + }; + } + + protected override void Update() + { + base.Update(); + + double time = gameplayClock?.CurrentTime ?? Time.Current; + + double songCurrentTime = time - startTime; + int currentPercent = Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); + int currentSecond = (int)Math.Floor(songCurrentTime / 1000.0); + + if (currentPercent != previousPercent) + { + progress.Text = currentPercent.ToString() + @"%"; + previousPercent = currentPercent; + } + + if (currentSecond != previousSecond && songCurrentTime < songLength) + { + timeCurrent.Text = formatTime(TimeSpan.FromSeconds(currentSecond)); + timeLeft.Text = formatTime(TimeSpan.FromMilliseconds(endTime - time)); + + previousSecond = currentSecond; + } + } + + private string formatTime(TimeSpan timeSpan) => $"{(timeSpan < TimeSpan.Zero ? "-" : "")}{Math.Floor(timeSpan.Duration().TotalMinutes)}:{timeSpan.Duration().Seconds:D2}"; + } +} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 8f80644d52..7833c2d7fa 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -9,7 +9,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; @@ -22,6 +21,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; using osu.Game.Skinning; using osuTK; @@ -34,21 +34,23 @@ namespace osu.Game.Screens.Play public const Easing FADE_EASING = Easing.OutQuint; - /// - /// The total height of all the top of screen scoring elements. - /// - public float TopScoringElementsHeight { get; private set; } - /// /// The total height of all the bottom of screen scoring elements. /// public float BottomScoringElementsHeight { get; private set; } + // HUD uses AlwaysVisible on child components so they can be in an updated state for next display. + // Without blocking input, this would also allow them to be interacted with in such a state. + public override bool PropagatePositionalInputSubTree => ShowHud.Value; + public readonly KeyCounterDisplay KeyCounter; public readonly ModDisplay ModDisplay; public readonly HoldForMenuButton HoldToQuit; public readonly PlayerSettingsOverlay PlayerSettingsOverlay; + [Cached] + private readonly ClicksPerSecondCalculator clicksPerSecondCalculator; + public Bindable ShowHealthBar = new Bindable(true); private readonly DrawableRuleset drawableRuleset; @@ -76,9 +78,15 @@ namespace osu.Game.Screens.Play private readonly SkinnableTargetContainer mainComponents; - private IEnumerable hideTargets => new Drawable[] { mainComponents, KeyCounter, topRightElements }; + /// + /// A flow which sits at the left side of the screen to house leaderboard (and related) components. + /// Will automatically be positioned to avoid colliding with top scoring elements. + /// + public readonly FillFlowContainer LeaderboardFlow; - public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods) + private readonly List hideTargets; + + public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) { this.drawableRuleset = drawableRuleset; this.mods = mods; @@ -122,8 +130,21 @@ namespace osu.Game.Screens.Play KeyCounter = CreateKeyCounter(), HoldToQuit = CreateHoldForMenuButton(), } - } + }, + LeaderboardFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(44), // enough margin to avoid the hit error display + Spacing = new Vector2(5) + }, + clicksPerSecondCalculator = new ClicksPerSecondCalculator(), }; + + hideTargets = new List { mainComponents, KeyCounter, topRightElements }; + + if (!alwaysShowLeaderboard) + hideTargets.Add(LeaderboardFlow); } [BackgroundDependencyLoader(true)] @@ -172,22 +193,36 @@ namespace osu.Game.Screens.Play { base.Update(); - Vector2? lowestTopScreenSpace = null; + float? lowestTopScreenSpaceLeft = null; + float? lowestTopScreenSpaceRight = null; + Vector2? highestBottomScreenSpace = null; // LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes. foreach (var element in mainComponents.Components.Cast()) { - // for now align top-right components with the bottom-edge of the lowest top-anchored hud element. - if (element.Anchor.HasFlagFast(Anchor.TopRight) || (element.Anchor.HasFlagFast(Anchor.y0) && element.RelativeSizeAxes == Axes.X)) + // for now align some top components with the bottom-edge of the lowest top-anchored hud element. + if (element.Anchor.HasFlagFast(Anchor.y0)) { // health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area. if (element is LegacyHealthDisplay) continue; - var bottomRight = element.ScreenSpaceDrawQuad.BottomRight; - if (lowestTopScreenSpace == null || bottomRight.Y > lowestTopScreenSpace.Value.Y) - lowestTopScreenSpace = bottomRight; + float bottom = element.ScreenSpaceDrawQuad.BottomRight.Y; + + bool isRelativeX = element.RelativeSizeAxes == Axes.X; + + if (element.Anchor.HasFlagFast(Anchor.TopRight) || isRelativeX) + { + if (lowestTopScreenSpaceRight == null || bottom > lowestTopScreenSpaceRight.Value) + lowestTopScreenSpaceRight = bottom; + } + + if (element.Anchor.HasFlagFast(Anchor.TopLeft) || isRelativeX) + { + if (lowestTopScreenSpaceLeft == null || bottom > lowestTopScreenSpaceLeft.Value) + lowestTopScreenSpaceLeft = bottom; + } } // and align bottom-right components with the top-edge of the highest bottom-anchored hud element. else if (element.Anchor.HasFlagFast(Anchor.BottomRight) || (element.Anchor.HasFlagFast(Anchor.y2) && element.RelativeSizeAxes == Axes.X)) @@ -198,11 +233,16 @@ namespace osu.Game.Screens.Play } } - if (lowestTopScreenSpace.HasValue) - topRightElements.Y = TopScoringElementsHeight = MathHelper.Clamp(ToLocalSpace(lowestTopScreenSpace.Value).Y, 0, DrawHeight - topRightElements.DrawHeight); + if (lowestTopScreenSpaceRight.HasValue) + topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight); else topRightElements.Y = 0; + if (lowestTopScreenSpaceLeft.HasValue) + LeaderboardFlow.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight); + else + LeaderboardFlow.Y = 0; + if (highestBottomScreenSpace.HasValue) bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); else @@ -259,7 +299,11 @@ namespace osu.Game.Screens.Play protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset) { - (drawableRuleset as ICanAttachKeyCounter)?.Attach(KeyCounter); + if (drawableRuleset is ICanAttachHUDPieces attachTarget) + { + attachTarget.Attach(KeyCounter); + attachTarget.Attach(clicksPerSecondCalculator); + } replayLoaded.BindTo(drawableRuleset.HasReplayLoaded); } diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs new file mode 100644 index 0000000000..83ba5f3474 --- /dev/null +++ b/osu.Game/Screens/Play/IGameplayClock.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.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.Play +{ + public interface IGameplayClock : IFrameBasedClock + { + /// + /// The time from which the clock should start. Will be seeked to on calling . + /// + /// + /// By default, a value of zero will be used. + /// Importantly, the value will be inferred from the current beatmap in by default. + /// + double StartTime { get; } + + /// + /// All adjustments applied to this clock which come from mods. + /// + IAdjustableAudioComponent AdjustmentsFromMods { get; } + + IBindable IsPaused { get; } + } +} diff --git a/osu.Game/Screens/Play/KeyCounterDisplay.cs b/osu.Game/Screens/Play/KeyCounterDisplay.cs index b6094726c0..1b726b0f7b 100644 --- a/osu.Game/Screens/Play/KeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/KeyCounterDisplay.cs @@ -39,6 +39,7 @@ namespace osu.Game.Screens.Play { Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, + Alpha = 0, }; } diff --git a/osu.Game/Screens/Play/KeyCounterState.cs b/osu.Game/Screens/Play/KeyCounterState.cs deleted file mode 100644 index 45d027e5ec..0000000000 --- a/osu.Game/Screens/Play/KeyCounterState.cs +++ /dev/null @@ -1,19 +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 - -namespace osu.Game.Screens.Play -{ - public class KeyCounterState - { - public KeyCounterState(double time, int count) - { - Time = time; - Count = count; - } - - public readonly double Time; - public readonly int Count; - } -} diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index cd37c541ec..c3c351ac36 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -1,12 +1,8 @@ // 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.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; @@ -15,8 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Configuration; -using osu.Game.Database; +using osu.Game.Overlays; namespace osu.Game.Screens.Play { @@ -37,37 +32,32 @@ 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, MinValue = 0.5, MaxValue = 2, Precision = 0.1, }; - private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; - - private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock. - private readonly WorkingBeatmap beatmap; - private HardwareCorrectionOffsetClock userGlobalOffsetClock; - private HardwareCorrectionOffsetClock userBeatmapOffsetClock; - private HardwareCorrectionOffsetClock platformOffsetClock; - private MasterGameplayClock masterGameplayClock; - private Bindable userAudioOffset; - - private IDisposable beatmapOffsetSubscription; + private readonly Track track; private readonly double skipTargetTime; - [Resolved] - private RealmAccess realm { get; set; } + /// + /// Stores the time at which the last call was triggered. + /// This is used to ensure we resume from that precise point in time, ignoring the proceeding frequency ramp. + /// + /// Optimally, we'd have gameplay ramp down with the frequency, but I believe this was intentionally disabled + /// to avoid fails occurring after the pause screen has been shown. + /// + /// In the future I want to change this. + /// + private double? actualStopTime; [Resolved] - private OsuConfigManager config { get; set; } + private MusicController musicController { get; set; } = null!; /// /// Create a new master gameplay clock container. @@ -75,32 +65,14 @@ namespace osu.Game.Screens.Play /// The beatmap to be used for time and metadata references. /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime) - : base(beatmap.Track) + : base(beatmap.Track, true) { this.beatmap = beatmap; this.skipTargetTime = skipTargetTime; - } - protected override void LoadComplete() - { - base.LoadComplete(); + track = beatmap.Track; - userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); - userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); - - beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( - r => r.Find(beatmap.BeatmapInfo.ID)?.UserSettings, - settings => settings.Offset, - val => userBeatmapOffsetClock.Offset = val); - - // Reset may have been called externally before LoadComplete. - // If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here. - bool isStarted = !IsPaused.Value; - - // If a custom start time was not specified, calculate the best value to use. - StartTime ??= findEarliestStartTime(); - - Reset(startClock: isStarted); + StartTime = findEarliestStartTime(); } private double findEarliestStartTime() @@ -126,54 +98,70 @@ namespace osu.Game.Screens.Play return time; } - protected override void OnIsPausedChanged(ValueChangedEvent isPaused) + protected override void StopGameplayClock() { + actualStopTime = GameplayClock.CurrentTime; + if (IsLoaded) { // During normal operation, the source is stopped after performing a frequency ramp. - if (isPaused.NewValue) + this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 0, 200, Easing.Out).OnComplete(_ => { - this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => - { - if (IsPaused.Value == isPaused.NewValue) - AdjustableSource.Stop(); - }); - } - else - this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); + if (IsPaused.Value) + base.StopGameplayClock(); + }); } else { - if (isPaused.NewValue) - AdjustableSource.Stop(); + base.StopGameplayClock(); // 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; + GameplayClock.ExternalPauseFrequencyAdjust.Value = 0; // 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(); + GameplayClock.ProcessFrame(); } } - public override void Start() - { - addSourceClockAdjustments(); - base.Start(); - } - - /// - /// Seek to a specific time in gameplay. - /// - /// - /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track). - /// - /// The destination time to seek to. public override void Seek(double time) { - // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track. - // we may want to consider reversing the application of offsets in the future as it may feel more correct. - base.Seek(time - totalAppliedOffset); + // Safety in case the clock is seeked while stopped. + actualStopTime = null; + + base.Seek(time); + } + + protected override void PrepareStart() + { + if (actualStopTime != null) + { + Seek(actualStopTime.Value); + actualStopTime = null; + } + else + base.PrepareStart(); + } + + protected override void StartGameplayClock() + { + addSourceClockAdjustments(); + + base.StartGameplayClock(); + + if (IsLoaded) + { + this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In); + } + else + { + // If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations. + GameplayClock.ExternalPauseFrequencyAdjust.Value = 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.ProcessFrame(); + } } /// @@ -193,19 +181,6 @@ namespace osu.Game.Screens.Play Seek(skipTarget); } - protected override GameplayClock 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 }; - - // 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); - } - /// /// Changes the backing clock to avoid using the originally provided track. /// @@ -223,11 +198,11 @@ namespace osu.Game.Screens.Play if (speedAdjustmentsApplied) return; - Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + musicController.ResetTrackAdjustments(); - masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); - masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); + track.BindAdjustments(AdjustmentsFromMods); + track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust); + track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); speedAdjustmentsApplied = true; } @@ -237,11 +212,9 @@ namespace osu.Game.Screens.Play if (!speedAdjustmentsApplied) return; - Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - - masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); - masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); + track.UnbindAdjustments(AdjustmentsFromMods); + track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust); + track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); speedAdjustmentsApplied = false; } @@ -249,68 +222,12 @@ namespace osu.Game.Screens.Play protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - beatmapOffsetSubscription?.Dispose(); removeSourceClockAdjustments(); } ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo; - IClock IBeatSyncProvider.Clock => GameplayClock; - ChannelAmplitudes? IBeatSyncProvider.Amplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : null; + IClock IBeatSyncProvider.Clock => this; - 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; - - public MasterGameplayClock(FramedOffsetClock underlyingClock) - : base(underlyingClock) - { - } - } + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; } } 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/Player.cs b/osu.Game/Screens/Play/Player.cs index b4016fc1cf..7048f83c09 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -4,10 +4,12 @@ #nullable disable using System; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -26,7 +28,6 @@ using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.IO.Archives; using osu.Game.Online.API; -using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -34,6 +35,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; @@ -64,6 +66,8 @@ namespace osu.Game.Screens.Play public override bool HideOverlaysOnEnter => true; + public override bool HideMenuCursorOnNonMouseInput => true; + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). @@ -78,7 +82,7 @@ namespace osu.Game.Screens.Play /// protected virtual bool PauseOnFocusLost => true; - public Action RestartRequested; + public Action RestartRequested; private bool isRestarting; @@ -92,6 +96,11 @@ namespace osu.Game.Screens.Play public int RestartCount; + /// + /// Whether the is currently visible. + /// + public IBindable ShowingOverlayComponents = new Bindable(); + [Resolved] private ScoreManager scoreManager { get; set; } @@ -101,9 +110,6 @@ namespace osu.Game.Screens.Play [Resolved] private MusicController musicController { get; set; } - [Resolved] - private SpectatorClient spectatorClient { get; set; } - public GameplayState GameplayState { get; private set; } private Ruleset ruleset; @@ -253,6 +259,9 @@ namespace osu.Game.Screens.Play // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. GameplayClockContainer.Add(rulesetSkinProvider); + if (cancellationToken.IsCancellationRequested) + return; + rulesetSkinProvider.AddRange(new Drawable[] { failAnimationLayer = new FailAnimation(DrawableRuleset) @@ -267,7 +276,8 @@ namespace osu.Game.Screens.Play }, FailOverlay = new FailOverlay { - OnRetry = Restart, + SaveReplay = prepareAndImportScore, + OnRetry = () => Restart(), OnQuit = () => PerformExit(true), }, new HotkeyExitOverlay @@ -282,6 +292,9 @@ namespace osu.Game.Screens.Play }, }); + if (cancellationToken.IsCancellationRequested) + return; + if (Configuration.AllowRestart) { rulesetSkinProvider.Add(new HotkeyRetryOverlay @@ -291,7 +304,7 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) return; fadeOut(true); - Restart(); + Restart(true); }, }); } @@ -327,7 +340,7 @@ namespace osu.Game.Screens.Play DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState()); // bind clock into components that require it - DrawableRuleset.IsPaused.BindTo(GameplayClockContainer.IsPaused); + ((IBindable)DrawableRuleset.IsPaused).BindTo(GameplayClockContainer.IsPaused); DrawableRuleset.NewResult += r => { @@ -368,6 +381,11 @@ namespace osu.Game.Screens.Play IsBreakTime.BindTo(breakTracker.IsBreakTime); IsBreakTime.BindValueChanged(onBreakTimeChanged, true); + + if (Configuration.AutomaticallySkipIntro) + skipIntroOverlay.SkipWhenReady(); + + loadLeaderboard(); } protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); @@ -410,7 +428,7 @@ namespace osu.Game.Screens.Play // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), - HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) + HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) { HoldToQuit = { @@ -438,7 +456,7 @@ namespace osu.Game.Screens.Play { OnResume = Resume, Retries = RestartCount, - OnRetry = Restart, + OnRetry = () => Restart(), OnQuit = () => PerformExit(true), }, }, @@ -472,7 +490,7 @@ namespace osu.Game.Screens.Play private void updateSampleDisabledState() { - samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.GameplayClock.IsPaused.Value; + samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.IsPaused.Value; } private void updatePauseOnFocusLostState() @@ -555,9 +573,6 @@ namespace osu.Game.Screens.Play /// protected void PerformExit(bool showDialogFirst) { - // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). - resultsDisplayDelegate?.Cancel(); - // there is a chance that an exit request occurs after the transition to results has already started. // even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process). if (!this.IsCurrentScreen()) @@ -592,6 +607,9 @@ namespace osu.Game.Screens.Play } } + // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). + resultsDisplayDelegate?.Cancel(); + // The actual exit is performed if // - the pause / fail dialog was not requested // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). @@ -634,8 +652,7 @@ namespace osu.Game.Screens.Play bool wasFrameStable = DrawableRuleset.FrameStablePlayback; DrawableRuleset.FrameStablePlayback = false; - GameplayClockContainer.StartTime = time; - GameplayClockContainer.Reset(); + GameplayClockContainer.Reset(time); // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); @@ -645,7 +662,8 @@ namespace osu.Game.Screens.Play /// Restart gameplay via a parent . /// This can be called from a child screen in order to trigger the restart process. /// - public void Restart() + /// Whether a quick restart was requested (skipping intro etc.). + public void Restart(bool quickRestart = false) { if (!Configuration.AllowRestart) return; @@ -657,7 +675,7 @@ namespace osu.Game.Screens.Play musicController.Stop(); sampleRestart?.Play(); - RestartRequested?.Invoke(); + RestartRequested?.Invoke(quickRestart); PerformExit(false); } @@ -720,7 +738,7 @@ namespace osu.Game.Screens.Play if (!Configuration.ShowResults) return; - prepareScoreForDisplayTask ??= Task.Run(prepareScoreForResults); + prepareScoreForDisplayTask ??= Task.Run(prepareAndImportScore); bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; @@ -739,7 +757,7 @@ namespace osu.Game.Screens.Play /// Asynchronously run score preparation operations (database import, online submission etc.). /// /// The final score. - private async Task prepareScoreForResults() + private async Task prepareAndImportScore() { var scoreCopy = Score.DeepClone(); @@ -769,19 +787,11 @@ namespace osu.Game.Screens.Play /// /// /// A final display will only occur once all work is completed in . This means that even after calling this method, the results screen will never be shown until ScoreProcessor.HasCompleted becomes . - /// - /// Calling this method multiple times will have no effect. /// /// Whether a minimum delay () should be added before the screen is displayed. private void progressToResults(bool withDelay) { - if (resultsDisplayDelegate != null) - // Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be - // accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued - // may take x00 more milliseconds than expected in the very rare edge case). - // - // If required we can handle this more correctly by rescheduling here. - return; + resultsDisplayDelegate?.Cancel(); double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0; @@ -813,6 +823,41 @@ namespace osu.Game.Screens.Play return mouseWheelDisabled.Value && !e.AltPressed; } + #region Gameplay leaderboard + + protected readonly Bindable LeaderboardExpandedState = new BindableBool(); + + private void loadLeaderboard() + { + HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); + LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); + + var gameplayLeaderboard = CreateGameplayLeaderboard(); + + if (gameplayLeaderboard != null) + { + LoadComponentAsync(gameplayLeaderboard, leaderboard => + { + if (!LoadedBeatmapSuccessfully) + return; + + leaderboard.Expanded.BindTo(LeaderboardExpandedState); + + AddLeaderboardToHUD(leaderboard); + }); + } + } + + [CanBeNull] + protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null; + + protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard); + + private void updateLeaderboardExpandedState() => + LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; + + #endregion + #region Fail Logic protected FailOverlay FailOverlay { get; private set; } @@ -821,11 +866,18 @@ namespace osu.Game.Screens.Play private bool onFail() { + // Failing after the quit sequence has started may cause weird side effects with the fail animation / effects. + if (GameplayState.HasQuit) + return false; + if (!CheckModsAllowFailure()) return false; + Debug.Assert(!GameplayState.HasFailed); + Debug.Assert(!GameplayState.HasPassed); + Debug.Assert(!GameplayState.HasQuit); + GameplayState.HasFailed = true; - Score.ScoreInfo.Passed = false; updateGameplayState(); @@ -838,14 +890,21 @@ namespace osu.Game.Screens.Play failAnimationLayer.Start(); if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) - Restart(); + Restart(true); return true; } - // Called back when the transform finishes + /// + /// Invoked when the fail animation has finished. + /// private void onFailComplete() { + // fail completion is a good point to mark a score as failed, + // since the last judgement that caused the fail only applies to score processor after onFail. + // todo: this should probably be handled better. + ScoreProcessor.FailScore(Score.ScoreInfo); + GameplayClockContainer.Stop(); FailOverlay.Retries = RestartCount; @@ -868,7 +927,7 @@ namespace osu.Game.Screens.Play private double? lastPauseActionTime; protected bool PauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown; + lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + pause_cooldown; /// /// A set of conditionals which defines whether the current game state and configuration allows for @@ -906,7 +965,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Stop(); PauseOverlay.Show(); - lastPauseActionTime = GameplayClockContainer.GameplayClock.CurrentTime; + lastPauseActionTime = GameplayClockContainer.CurrentTime; return true; } @@ -963,6 +1022,8 @@ namespace osu.Game.Screens.Play }); HUDOverlay.IsPlaying.BindTo(localUserPlaying); + ShowingOverlayComponents.BindTo(HUDOverlay.ShowHud); + DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); @@ -975,12 +1036,8 @@ namespace osu.Game.Screens.Play foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToHUD(HUDOverlay); - // Our mods are local copies of the global mods so they need to be re-applied to the track. - // This is done through the music controller (for now), because resetting speed adjustments on the beatmap track also removes adjustments provided by DrawableTrack. - // Todo: In the future, player will receive in a track and will probably not have to worry about this... - musicController.ResetTrackAdjustments(); foreach (var mod in GameplayState.Mods.OfType()) - mod.ApplyToTrack(musicController.CurrentTrack); + mod.ApplyToTrack(GameplayClockContainer.AdjustmentsFromMods); updateGameplayState(); @@ -996,10 +1053,10 @@ namespace osu.Game.Screens.Play /// protected virtual void StartGameplay() { - if (GameplayClockContainer.GameplayClock.IsRunning) + if (GameplayClockContainer.IsRunning) throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"); - GameplayClockContainer.Reset(true); + GameplayClockContainer.Reset(startClock: true); } public override void OnSuspending(ScreenTransitionEvent e) @@ -1022,16 +1079,7 @@ namespace osu.Game.Screens.Play // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. if (prepareScoreForDisplayTask == null) - { - Score.ScoreInfo.Passed = false; - // potentially should be ScoreRank.F instead? this is the best alternative for now. - Score.ScoreInfo.Rank = ScoreRank.D; - } - - // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. - // To resolve test failures, forcefully end playing synchronously when this screen exits. - // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method. - spectatorClient.EndPlaying(GameplayState); + ScoreProcessor.FailScore(Score.ScoreInfo); } // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. @@ -1041,6 +1089,7 @@ namespace osu.Game.Screens.Play musicController.ResetTrackAdjustments(); fadeOut(); + return base.OnExiting(e); } diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index d11825baee..b82925ccb8 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -31,5 +31,15 @@ namespace osu.Game.Screens.Play /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard. /// public bool AllowSkipping { get; set; } = true; + + /// + /// Whether the intro should be skipped by default. + /// + public bool AutomaticallySkipIntro { get; set; } + + /// + /// Whether the gameplay leaderboard should always be shown (usually in a contracted state). + /// + public bool AlwaysShowLeaderboard { get; set; } } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 5d7319c51f..4ff5083107 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -64,6 +64,8 @@ namespace osu.Game.Screens.Play protected Task? DisposalTask { get; private set; } + private OsuScrollContainer settingsScroll = null!; + private bool backgroundBrightnessReduction; private readonly BindableDouble volumeAdjustment = new BindableDouble(1); @@ -71,6 +73,9 @@ namespace osu.Game.Screens.Play private AudioFilter lowPassFilter = null!; private AudioFilter highPassFilter = null!; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + protected bool BackgroundBrightnessReduction { set @@ -123,6 +128,8 @@ namespace osu.Game.Screens.Play private EpilepsyWarning? epilepsyWarning; + private bool quickRestart; + [Resolved(CanBeNull = true)] private INotificationOverlay? notificationOverlay { get; set; } @@ -163,30 +170,30 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - new OsuScrollContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, - Padding = new MarginPadding { Vertical = padding }, - Masking = false, - Child = PlayerSettings = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Padding = new MarginPadding { Horizontal = padding }, - Children = new PlayerSettingsGroup[] - { - VisualSettings = new VisualSettings(), - AudioSettings = new AudioSettings(), - new InputSettings() - } - }, - }, - idleTracker = new IdleTracker(750), }), + settingsScroll = new OsuScrollContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, + Padding = new MarginPadding { Vertical = padding }, + Masking = false, + Child = PlayerSettings = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Padding = new MarginPadding { Horizontal = padding }, + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + AudioSettings = new AudioSettings(), + new InputSettings() + } + }, + }, + idleTracker = new IdleTracker(750), lowPassFilter = new AudioFilter(audio.TrackMixer), highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass) }; @@ -222,6 +229,9 @@ namespace osu.Game.Screens.Play Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + // Start off-screen. + settingsScroll.MoveToX(settingsScroll.DrawWidth); + content.ScaleTo(0.7f); contentIn(); @@ -311,6 +321,16 @@ namespace osu.Game.Screens.Play content.StopTracking(); } + protected override void LogoSuspending(OsuLogo logo) + { + base.LogoSuspending(logo); + content.StopTracking(); + + logo + .FadeOut(CONTENT_OUT_DURATION / 2, Easing.OutQuint) + .ScaleTo(logo.Scale * 0.8f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + } + #endregion protected override void Update() @@ -361,6 +381,7 @@ namespace osu.Game.Screens.Play return; CurrentPlayer = createPlayer(); + CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart; CurrentPlayer.RestartCount = restartCount++; CurrentPlayer.RestartRequested = restartRequested; @@ -375,8 +396,9 @@ namespace osu.Game.Screens.Play { } - private void restartRequested() + private void restartRequested(bool quickRestartRequested) { + quickRestart = quickRestartRequested; hideOverlays = true; ValidForResume = true; } @@ -387,6 +409,10 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + + settingsScroll.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); highPassFilter.CutoffTo(300).Then().CutoffTo(0, 1250); // 1250 is to line up with the appearance of MetadataInfo (750 delay + 500 fade-in) @@ -400,6 +426,10 @@ namespace osu.Game.Screens.Play content.ScaleTo(0.7f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); content.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint); + + settingsScroll.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) + .MoveToX(settingsScroll.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION); highPassFilter.CutoffTo(0, CONTENT_OUT_DURATION); } @@ -428,7 +458,7 @@ namespace osu.Game.Screens.Play ContentOut(); - TransformSequence pushSequence = this.Delay(CONTENT_OUT_DURATION); + TransformSequence pushSequence = this.Delay(0); // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). @@ -437,6 +467,7 @@ namespace osu.Game.Screens.Play const double epilepsy_display_length = 3000; pushSequence + .Delay(CONTENT_OUT_DURATION) .Schedule(() => epilepsyWarning.State.Value = Visibility.Visible) .TransformBindableTo(volumeAdjustment, 0.25, EpilepsyWarning.FADE_DURATION, Easing.OutQuint) .Delay(epilepsy_display_length) @@ -498,7 +529,7 @@ namespace osu.Game.Screens.Play private int restartCount; - private const double volume_requirement = 0.05; + private const double volume_requirement = 0.01; private void showMuteWarningIfNeeded() { @@ -526,7 +557,7 @@ namespace osu.Game.Screens.Play private void load(OsuColour colours, AudioManager audioManager, INotificationOverlay notificationOverlay, VolumeOverlay volumeOverlay) { Icon = FontAwesome.Solid.VolumeMute; - IconBackground.Colour = colours.RedDark; + IconContent.Colour = colours.RedDark; Activated = delegate { @@ -535,10 +566,11 @@ namespace osu.Game.Screens.Play volumeOverlay.IsMuted.Value = false; // Check values before resetting, as the user may have only had mute enabled, in which case we might not need to adjust volumes. + // Note that we only restore halfway to ensure the user isn't suddenly overloaded by unexpectedly high volume. if (audioManager.Volume.Value <= volume_requirement) - audioManager.Volume.SetDefault(); + audioManager.Volume.Value = 0.5f; if (audioManager.VolumeTrack.Value <= volume_requirement) - audioManager.VolumeTrack.SetDefault(); + audioManager.VolumeTrack.Value = 0.5f; return true; }; @@ -549,6 +581,8 @@ namespace osu.Game.Screens.Play #region Low battery warning + private const double low_battery_threshold = 0.25; + private Bindable batteryWarningShownOnce = null!; private void showBatteryWarningIfNeeded() @@ -557,7 +591,7 @@ namespace osu.Game.Screens.Play if (!batteryWarningShownOnce.Value) { - if (!batteryInfo.IsCharging && batteryInfo.ChargeLevel <= 0.25) + if (batteryInfo.OnBattery && batteryInfo.ChargeLevel <= low_battery_threshold) { notificationOverlay?.Post(new BatteryWarningNotification()); batteryWarningShownOnce.Value = true; @@ -578,7 +612,7 @@ namespace osu.Game.Screens.Play private void load(OsuColour colours, INotificationOverlay notificationOverlay) { Icon = FontAwesome.Solid.BatteryQuarter; - IconBackground.Colour = colours.RedDark; + IconContent.Colour = colours.RedDark; Activated = delegate { diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 75da8e7b9d..537f4d811a 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -31,8 +31,6 @@ namespace osu.Game.Screens.Play.PlayerSettings public BindableDouble Current { get; } = new BindableDouble { - Default = 0, - Value = 0, MinValue = -50, MaxValue = 50, Precision = 0.1, diff --git a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs index 12646d656a..14e3123028 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs @@ -17,7 +17,6 @@ namespace osu.Game.Screens.Play.PlayerSettings public readonly Bindable UserPlaybackRate = new BindableDouble(1) { - Default = 1, MinValue = 0.5, MaxValue = 2, Precision = 0.1, diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 64b4853a67..5382e283e0 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using osu.Framework.Bindables; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Beatmaps; @@ -14,6 +15,7 @@ using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play @@ -22,12 +24,21 @@ namespace osu.Game.Screens.Play { private readonly Func, Score> createScore; + private readonly bool replayIsFailedScore; + // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) - protected override bool CheckModsAllowFailure() => false; + protected override bool CheckModsAllowFailure() + { + if (!replayIsFailedScore) + return false; + + return base.CheckModsAllowFailure(); + } public ReplayPlayer(Score score, PlayerConfiguration configuration = null) : this((_, _) => score, configuration) { + replayIsFailedScore = score.ScoreInfo.Rank == ScoreRank.F; } public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) @@ -46,6 +57,15 @@ namespace osu.Game.Screens.Play // Don't re-import replay scores as they're already present in the database. protected override Task ImportScore(Score score) => Task.CompletedTask; + public readonly BindableList LeaderboardScores = new BindableList(); + + protected override GameplayLeaderboard CreateGameplayLeaderboard() => + new SoloGameplayLeaderboard(Score.ScoreInfo.User) + { + AlwaysVisible = { Value = true }, + Scores = { BindTarget = LeaderboardScores } + }; + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); public bool OnPressed(KeyBindingPressEvent e) 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/SaveFailedScoreButton.cs b/osu.Game/Screens/Play/SaveFailedScoreButton.cs new file mode 100644 index 0000000000..7358ff3de4 --- /dev/null +++ b/osu.Game/Screens/Play/SaveFailedScoreButton.cs @@ -0,0 +1,91 @@ +// 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.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Scoring; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public class SaveFailedScoreButton : CompositeDrawable + { + private readonly Bindable state = new Bindable(); + + private readonly Func> importFailedScore; + + private ScoreInfo? importedScore; + + private DownloadButton button = null!; + + public SaveFailedScoreButton(Func> importFailedScore) + { + Size = new Vector2(50, 30); + + this.importFailedScore = importFailedScore; + } + + [BackgroundDependencyLoader] + private void load(OsuGame? game, Player? player, RealmAccess realm) + { + InternalChild = button = new DownloadButton + { + RelativeSizeAxes = Axes.Both, + State = { BindTarget = state }, + Action = () => + { + switch (state.Value) + { + case DownloadState.LocallyAvailable: + game?.PresentScore(importedScore, ScorePresentType.Gameplay); + break; + + case DownloadState.NotDownloaded: + state.Value = DownloadState.Importing; + Task.Run(importFailedScore).ContinueWith(t => + { + importedScore = realm.Run(r => r.Find(t.GetResultSafely().ID)?.Detach()); + Schedule(() => state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded); + }); + break; + } + } + }; + + if (player != null) + { + importedScore = realm.Run(r => r.Find(player.Score.ScoreInfo.ID)?.Detach()); + state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded; + } + + state.BindValueChanged(state => + { + switch (state.NewValue) + { + case DownloadState.LocallyAvailable: + button.TooltipText = @"watch replay"; + button.Enabled.Value = true; + break; + + case DownloadState.Importing: + button.TooltipText = @"importing score"; + button.Enabled.Value = false; + break; + + default: + button.TooltipText = @"save score"; + button.Enabled.Value = true; + break; + } + }, true); + } + } +} diff --git a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs index 59b92a1b97..cc1254975c 100644 --- a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs +++ b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Play public class ScreenSuspensionHandler : Component { private readonly GameplayClockContainer gameplayClockContainer; - private Bindable isPaused; + private IBindable isPaused; private readonly Bindable disableSuspensionBindable = new Bindable(); diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 3e2cf9a756..974d40b538 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -39,9 +39,12 @@ namespace osu.Game.Screens.Play private double displayTime; private bool isClickable; + private bool skipQueued; [Resolved] - private GameplayClock gameplayClock { get; set; } + private IGameplayClock gameplayClock { get; set; } + + internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -111,25 +114,45 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); + displayTime = gameplayClock.CurrentTime; + // skip is not required if there is no extra "empty" time to skip. // we may need to remove this if rewinding before the initial player load position becomes a thing. - if (fadeOutBeginTime < gameplayClock.CurrentTime) + if (fadeOutBeginTime <= displayTime) { Expire(); return; } button.Action = () => RequestSkip?.Invoke(); - displayTime = gameplayClock.CurrentTime; fadeContainer.TriggerShow(); + + if (skipQueued) + { + Scheduler.AddDelayed(() => button.TriggerClick(), 200); + skipQueued = false; + } + } + + public void SkipWhenReady() + { + if (IsLoaded) + button.TriggerClick(); + else + skipQueued = true; } protected override void Update() { base.Update(); - double progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); + // This case causes an immediate expire in `LoadComplete`, but `Update` may run once after that. + // Avoid div-by-zero below. + if (fadeOutBeginTime <= displayTime) + return; + + double progress = Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index 565f256277..ee19391b89 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -5,12 +5,15 @@ using System; using System.Diagnostics; +using System.Threading.Tasks; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.Play { @@ -40,8 +43,27 @@ namespace osu.Game.Screens.Play return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash); } + public readonly BindableList LeaderboardScores = new BindableList(); + + protected override GameplayLeaderboard CreateGameplayLeaderboard() => + new SoloGameplayLeaderboard(Score.ScoreInfo.User) + { + AlwaysVisible = { Value = false }, + Scores = { BindTarget = LeaderboardScores } + }; + protected override bool HandleTokenRetrievalFailure(Exception exception) => false; + protected override Task ImportScore(Score score) + { + // Before importing a score, stop binding the leaderboard with its score source. + // This avoids a case where the imported score may cause a leaderboard refresh + // (if the leaderboard's source is local). + LeaderboardScores.UnbindBindings(); + + return base.ImportScore(score); + } + protected override APIRequest CreateSubmissionRequest(Score score, long token) { IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo; diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs index 7eaba40640..9ef05c3a05 100644 --- a/osu.Game/Screens/Play/SoloSpectator.cs +++ b/osu.Game/Screens/Play/SoloSpectator.cs @@ -182,7 +182,7 @@ namespace osu.Game.Screens.Play scheduleStart(spectatorGameplayState); } - protected override void EndGameplay(int userId, SpectatorState state) + protected override void QuitGameplay(int userId) { scheduledStart?.Cancel(); immediateSpectatorGameplayState = null; diff --git a/osu.Game/Screens/Play/SongProgressInfo.cs b/osu.Game/Screens/Play/SongProgressInfo.cs deleted file mode 100644 index 40759c3a3b..0000000000 --- a/osu.Game/Screens/Play/SongProgressInfo.cs +++ /dev/null @@ -1,110 +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.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using System; - -namespace osu.Game.Screens.Play -{ - public class SongProgressInfo : Container - { - private OsuSpriteText timeCurrent; - private OsuSpriteText timeLeft; - private OsuSpriteText progress; - - private double startTime; - private double endTime; - - private int? previousPercent; - private int? previousSecond; - - private double songLength => endTime - startTime; - - private const int margin = 10; - - public double StartTime - { - set => startTime = value; - } - - public double EndTime - { - set => endTime = value; - } - - private GameplayClock gameplayClock; - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, GameplayClock clock) - { - if (clock != null) - gameplayClock = clock; - - Children = new Drawable[] - { - timeCurrent = new OsuSpriteText - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - Margin = new MarginPadding - { - Left = margin, - }, - }, - progress = new OsuSpriteText - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - }, - timeLeft = new OsuSpriteText - { - Origin = Anchor.BottomRight, - Anchor = Anchor.BottomRight, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - Margin = new MarginPadding - { - Right = margin, - }, - } - }; - } - - protected override void Update() - { - base.Update(); - - double time = gameplayClock?.CurrentTime ?? Time.Current; - - double songCurrentTime = time - startTime; - int currentPercent = Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); - int currentSecond = (int)Math.Floor(songCurrentTime / 1000.0); - - if (currentPercent != previousPercent) - { - progress.Text = currentPercent.ToString() + @"%"; - previousPercent = currentPercent; - } - - if (currentSecond != previousSecond && songCurrentTime < songLength) - { - timeCurrent.Text = formatTime(TimeSpan.FromSeconds(currentSecond)); - timeLeft.Text = formatTime(TimeSpan.FromMilliseconds(endTime - time)); - - previousSecond = currentSecond; - } - } - - private string formatTime(TimeSpan timeSpan) => $"{(timeSpan < TimeSpan.Zero ? "-" : "")}{Math.Floor(timeSpan.Duration().TotalMinutes)}:{timeSpan.Duration().Seconds:D2}"; - } -} diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index 797787b194..68cc21fc1c 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -84,6 +85,7 @@ namespace osu.Game.Screens.Play foreach (var frame in bundle.Frames) { IConvertibleReplayFrame convertibleFrame = GameplayState.Ruleset.CreateConvertibleReplayFrame(); + Debug.Assert(convertibleFrame != null); convertibleFrame.FromLegacy(frame, GameplayState.Beatmap); var convertedFrame = (ReplayFrame)convertibleFrame; diff --git a/osu.Game/Screens/Play/SquareGraph.cs b/osu.Game/Screens/Play/SquareGraph.cs index 00d6ede3bf..9ac673ae52 100644 --- a/osu.Game/Screens/Play/SquareGraph.cs +++ b/osu.Game/Screens/Play/SquareGraph.cs @@ -15,7 +15,6 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Allocation; -using osu.Framework.Layout; using osu.Framework.Threading; namespace osu.Game.Screens.Play @@ -24,11 +23,6 @@ namespace osu.Game.Screens.Play { private BufferedContainer columns; - public SquareGraph() - { - AddLayout(layout); - } - public int ColumnCount => columns?.Children.Count ?? 0; private int progress; @@ -57,7 +51,7 @@ namespace osu.Game.Screens.Play if (value == values) return; values = value; - layout.Invalidate(); + graphNeedsUpdate = true; } } @@ -75,21 +69,25 @@ namespace osu.Game.Screens.Play } } - private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize); private ScheduledDelegate scheduledCreate; + private bool graphNeedsUpdate; + + private Vector2 previousDrawSize; + protected override void Update() { base.Update(); - if (values != null && !layout.IsValid) + if (graphNeedsUpdate || (values != null && DrawSize != previousDrawSize)) { columns?.FadeOut(500, Easing.OutQuint).Expire(); scheduledCreate?.Cancel(); scheduledCreate = Scheduler.AddDelayed(RecreateGraph, 500); - layout.Validate(); + previousDrawSize = DrawSize; + graphNeedsUpdate = false; } } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 1cc0d1853d..345bd5a134 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -8,11 +8,13 @@ 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; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.Rooms; +using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -31,6 +33,9 @@ namespace osu.Game.Screens.Play [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private SpectatorClient spectatorClient { get; set; } + private TaskCompletionSource scoreSubmissionSource; protected SubmittingPlayer(PlayerConfiguration configuration = null) @@ -71,6 +76,7 @@ namespace osu.Game.Screens.Play req.Success += r => { + Logger.Log($"Score submission token retrieved ({r.ID})"); token = r.ID; tcs.SetResult(true); }; @@ -78,11 +84,16 @@ 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)) + req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); + return true; void handleTokenFailure(Exception exception) { + tcs.SetResult(false); + if (HandleTokenRetrievalFailure(exception)) { if (string.IsNullOrEmpty(exception.Message)) @@ -96,8 +107,12 @@ namespace osu.Game.Screens.Play this.Exit(); }); } - - tcs.SetResult(false); + else + { + // Gameplay is allowed to continue, but we still should keep track of the error. + // In the future, this should be visible to the user in some way. + Logger.Log($"Score submission token retrieval failed ({exception.Message})"); + } } } @@ -117,12 +132,34 @@ namespace osu.Game.Screens.Play await submitScore(score).ConfigureAwait(false); } + [Resolved] + private RealmAccess realm { get; set; } + + protected override void StartGameplay() + { + base.StartGameplay(); + + // User expectation is that last played should be updated when entering the gameplay loop + // from multiplayer / playlists / solo. + realm.WriteAsync(r => + { + var realmBeatmap = r.Find(Beatmap.Value.BeatmapInfo.ID); + if (realmBeatmap != null) + realmBeatmap.LastPlayed = DateTimeOffset.Now; + }); + + spectatorClient.BeginPlaying(GameplayState, Score); + } + public override bool OnExiting(ScreenExitEvent e) { bool exiting = base.OnExiting(e); if (LoadedBeatmapSuccessfully) + { submitScore(Score.DeepClone()); + spectatorClient.EndPlaying(GameplayState); + } return exiting; } @@ -168,7 +205,7 @@ namespace osu.Game.Screens.Play request.Failure += e => { - Logger.Error(e, "Failed to submit score"); + Logger.Error(e, $"Failed to submit score ({e.Message})"); scoreSubmissionSource.SetResult(false); }; diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index 7b8b4ce608..3e67868f34 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -214,7 +215,7 @@ namespace osu.Game.Screens.Ranking.Contracted { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Text = key, + Text = key.ToTitle(), Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) }, new OsuSpriteText diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 5b8777d2a4..829ba83696 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -7,12 +7,14 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -67,12 +69,10 @@ namespace osu.Game.Screens.Ranking.Expanded var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; - int? beatmapMaxCombo = scoreManager.GetMaximumAchievableComboAsync(score).GetResultSafely(); - var topStatistics = new List { new AccuracyStatistic(score.Accuracy), - new ComboStatistic(score.MaxCombo, beatmapMaxCombo), + new ComboStatistic(score.MaxCombo, scoreManager.GetMaximumAchievableCombo(score)), new PerformanceStatistic(score), }; @@ -133,7 +133,7 @@ namespace osu.Game.Screens.Ranking.Expanded FillMode = FillMode.Fit, } }, - scoreCounter = new TotalScoreCounter + scoreCounter = new TotalScoreCounter(!withFlair) { Margin = new MarginPadding { Top = 0, Bottom = 5 }, Current = { Value = 0 }, @@ -282,12 +282,34 @@ namespace osu.Game.Screens.Ranking.Expanded public class PlayedOnText : OsuSpriteText { + private readonly DateTimeOffset time; + private readonly Bindable prefer24HourTime = new Bindable(); + public PlayedOnText(DateTimeOffset time) { + this.time = time; + Anchor = Anchor.BottomCentre; Origin = Anchor.BottomCentre; Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); - Text = $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}"; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + Text = prefer24HourTime.Value ? $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}" : $"Played on {time.ToLocalTime():d MMMM yyyy h:mm tt}"; } } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs index d2b2a842b8..2708090855 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs @@ -4,6 +4,8 @@ #nullable disable using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -21,13 +23,19 @@ namespace osu.Game.Screens.Ranking.Expanded { private readonly APIUser user; + private Sample appearanceSample; + + private readonly bool playAppearanceSound; + /// /// Creates a new . /// /// The to display. - public ExpandedPanelTopContent(APIUser user) + /// Whether the appearance sample should play + public ExpandedPanelTopContent(APIUser user, bool playAppearanceSound = false) { this.user = user; + this.playAppearanceSound = playAppearanceSound; Anchor = Anchor.TopCentre; Origin = Anchor.Centre; @@ -35,8 +43,10 @@ namespace osu.Game.Screens.Ranking.Expanded } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + appearanceSample = audio.Samples.Get(@"Results/score-panel-top-appear"); + InternalChild = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -62,5 +72,13 @@ namespace osu.Game.Screens.Ranking.Expanded } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (playAppearanceSound) + appearanceSample?.Play(); + } } } diff --git a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs index 0b06dedc44..c7286a1838 100644 --- a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs +++ b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs @@ -3,7 +3,11 @@ #nullable disable +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -22,11 +26,35 @@ namespace osu.Game.Screens.Ranking.Expanded protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; - public TotalScoreCounter() + private readonly bool playSamples; + + private readonly Bindable tickPlaybackRate = new Bindable(); + + private double lastSampleTime; + + private DrawableSample sampleTick; + + public TotalScoreCounter(bool playSamples = false) { // Todo: AutoSize X removed here due to https://github.com/ppy/osu-framework/issues/3369 AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; + + this.playSamples = playSamples; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + AddInternal(sampleTick = new DrawableSample(audio.Samples.Get(@"Results/score-tick-lesser"))); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (playSamples) + Current.BindValueChanged(_ => startTicking()); } protected override LocalisableString FormatCount(long count) => count.ToString("N0"); @@ -39,5 +67,35 @@ namespace osu.Game.Screens.Ranking.Expanded s.Font = OsuFont.Torus.With(size: 60, weight: FontWeight.Light, fixedWidth: true); s.Spacing = new Vector2(-5, 0); }); + + public override long DisplayedCount + { + get => base.DisplayedCount; + set + { + if (base.DisplayedCount == value) + return; + + base.DisplayedCount = value; + + if (playSamples && Time.Current > lastSampleTime + tickPlaybackRate.Value) + { + sampleTick?.Play(); + lastSampleTime = Time.Current; + } + } + } + + private void startTicking() + { + const double tick_debounce_rate_start = 10f; + const double tick_debounce_rate_end = 100f; + const double tick_volume_start = 0.5f; + const double tick_volume_end = 1.0f; + + this.TransformBindableTo(tickPlaybackRate, tick_debounce_rate_start); + this.TransformBindableTo(tickPlaybackRate, tick_debounce_rate_end, RollingDuration, Easing.OutSine); + sampleTick.VolumeTo(tick_volume_start).Then().VolumeTo(tick_volume_end, RollingDuration, Easing.OutSine); + } } } diff --git a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs index 954a5159b9..7081a0156e 100644 --- a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Ranking if (State.Value == DownloadState.LocallyAvailable) return ReplayAvailability.Local; - if (!string.IsNullOrEmpty(Score.Value?.Hash)) + if (Score.Value?.HasReplay == true) return ReplayAvailability.Online; return ReplayAvailability.NotAvailable; diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 2f8868c06d..486df8653f 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -7,6 +7,8 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -60,6 +62,8 @@ namespace osu.Game.Screens.Ranking private readonly bool allowRetry; private readonly bool allowWatchingReplay; + private Sample popInSample; + protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) { Score = score; @@ -70,10 +74,12 @@ namespace osu.Game.Screens.Ranking } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { FillFlowContainer buttons; + popInSample = audio.Samples.Get(@"UI/overlay-pop-in"); + InternalChild = new GridContainer { RelativeSizeAxes = Axes.Both, @@ -171,7 +177,7 @@ namespace osu.Game.Screens.Ranking { if (!this.IsCurrentScreen()) return; - player?.Restart(); + player?.Restart(true); }, }); } @@ -244,6 +250,8 @@ namespace osu.Game.Screens.Ranking }); bottomPanel.FadeTo(1, 250); + + popInSample?.Play(); } public override bool OnExiting(ScreenExitEvent e) @@ -309,7 +317,7 @@ namespace osu.Game.Screens.Ranking var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft; // Remove from the local container and re-attach. - detachedPanelContainer.Remove(detachedPanel); + detachedPanelContainer.Remove(detachedPanel, false); ScorePanelList.Attach(detachedPanel); // Move into its original location in the attached container first, then to the final location. diff --git a/osu.Game/Screens/Ranking/RetryButton.cs b/osu.Game/Screens/Ranking/RetryButton.cs index a0ddc98943..c56f364ae8 100644 --- a/osu.Game/Screens/Ranking/RetryButton.cs +++ b/osu.Game/Screens/Ranking/RetryButton.cs @@ -37,7 +37,7 @@ namespace osu.Game.Screens.Ranking Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(13), - Icon = FontAwesome.Solid.ArrowCircleLeft, + Icon = FontAwesome.Solid.Redo, }, }; diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index babdd4b149..cb777de144 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -6,13 +6,16 @@ using System; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Contracted; using osu.Game.Screens.Ranking.Expanded; @@ -93,9 +96,12 @@ namespace osu.Game.Screens.Ranking public readonly ScoreInfo Score; - private bool displayWithFlair; + [Resolved] + private OsuGameBase game { get; set; } - private Container content; + private AudioContainer audioContent; + + private bool displayWithFlair; private Container topLayerContainer; private Drawable topLayerBackground; @@ -107,6 +113,8 @@ namespace osu.Game.Screens.Ranking private Container middleLayerContentContainer; private Drawable middleLayerContent; + private DrawableSample samplePanelFocus; + public ScorePanel(ScoreInfo score, bool isNewLocalScore = false) { Score = score; @@ -116,13 +124,13 @@ namespace osu.Game.Screens.Ranking } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { // ScorePanel doesn't include the top extruding area in its own size. // Adding a manual offset here allows the expanded version to take on an "acceptable" vertical centre when at 100% UI scale. const float vertical_fudge = 20; - InternalChild = content = new Container + InternalChild = audioContent = new AudioContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -174,7 +182,8 @@ namespace osu.Game.Screens.Ranking }, middleLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } } - } + }, + samplePanelFocus = new DrawableSample(audio.Samples.Get(@"Results/score-panel-focus")) } }; } @@ -202,12 +211,32 @@ namespace osu.Game.Screens.Ranking state = value; if (IsLoaded) + { updateState(); + if (value == PanelState.Expanded) + playAppearSample(); + } + StateChanged?.Invoke(value); } } + protected override void Update() + { + base.Update(); + audioContent.Balance.Value = (ScreenSpaceDrawQuad.Centre.X / game.ScreenSpaceDrawQuad.Width) * 2 - 1; + } + + private void playAppearSample() + { + var channel = samplePanelFocus?.GetChannel(); + if (channel == null) return; + + channel.Frequency.Value = 0.99 + RNG.NextDouble(0.2); + channel.Play(); + } + private void updateState() { topLayerContent?.FadeOut(content_fade_duration).Expire(); @@ -221,7 +250,8 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(expanded_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); middleLayerBackground.FadeColour(expanded_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); - topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User) { Alpha = 0 }); + bool firstLoad = topLayerContent == null; + topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User, firstLoad) { Alpha = 0 }); middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair) { Alpha = 0 }); // only the first expanded display should happen with flair. @@ -244,7 +274,7 @@ namespace osu.Game.Screens.Ranking break; } - content.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint); + audioContent.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint); bool topLayerExpanded = topLayerContainer.Y < 0; diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 4f9e61a4a1..46f9efd126 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -8,11 +8,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -151,32 +149,27 @@ namespace osu.Game.Screens.Ranking var score = trackingContainer.Panel.Score; - // Calculating score can take a while in extreme scenarios, so only display scores after the process completes. - scoreManager.GetTotalScoreAsync(score) - .ContinueWith(task => Schedule(() => - { - flow.SetLayoutPosition(trackingContainer, task.GetResultSafely()); + flow.SetLayoutPosition(trackingContainer, scoreManager.GetTotalScore(score)); - trackingContainer.Show(); + trackingContainer.Show(); - if (SelectedScore.Value?.Equals(score) == true) - { - SelectedScore.TriggerChange(); - } - else - { - // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. - // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. - if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) - { - // A somewhat hacky property is used here because we need to: - // 1) Scroll after the scroll container's visible range is updated. - // 2) Scroll before the scroll container's scroll position is updated. - // Without this, we would have a 1-frame positioning error which looks very jarring. - scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; - } - } - }), TaskContinuationOptions.OnlyOnRanToCompletion); + if (SelectedScore.Value?.Equals(score) == true) + { + SelectedScore.TriggerChange(); + } + else + { + // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. + // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. + if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) + { + // A somewhat hacky property is used here because we need to: + // 1) Scroll after the scroll container's visible range is updated. + // 2) Scroll before the scroll container's scroll position is updated. + // Without this, we would have a 1-frame positioning error which looks very jarring. + scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; + } + } } /// diff --git a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs index e55c4530b4..b4d6d481ef 100644 --- a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs +++ b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs @@ -34,7 +34,7 @@ namespace osu.Game.Screens.Ranking if (InternalChildren.Count == 0) throw new InvalidOperationException("Score panel container is not attached."); - RemoveInternal(Panel); + RemoveInternal(Panel, false); } /// diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 4813cd65db..4312c9528f 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Ranking return null; getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.CreateScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); + getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); return getScoreRequest; } diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index db69e270f6..764237ef96 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -1,13 +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 System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -48,6 +45,12 @@ namespace osu.Game.Screens.Ranking.Statistics /// private readonly IReadOnlyList hitEvents; + private readonly IDictionary[] bins; + private double binSize; + private double hitOffset; + + private Bar[]? barDrawables; + /// /// Creates a new . /// @@ -55,22 +58,15 @@ namespace osu.Game.Screens.Ranking.Statistics public HitEventTimingDistributionGraph(IReadOnlyList hitEvents) { this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList(); + bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary()).ToArray>(); } - private int[] bins; - private double binSize; - private double hitOffset; - - private Bar[] barDrawables; - [BackgroundDependencyLoader] private void load() { - if (hitEvents == null || hitEvents.Count == 0) + if (hitEvents.Count == 0) return; - bins = new int[total_timing_distribution_bins]; - binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); // Prevent div-by-0 by enforcing a minimum bin size @@ -89,7 +85,8 @@ namespace osu.Game.Screens.Ranking.Statistics { bool roundUp = true; - Array.Clear(bins, 0, bins.Length); + foreach (var bin in bins) + bin.Clear(); foreach (var e in hitEvents) { @@ -110,23 +107,23 @@ namespace osu.Game.Screens.Ranking.Statistics // may be out of range when applying an offset. for such cases we can just drop the results. if (index >= 0 && index < bins.Length) - bins[index]++; + { + bins[index].TryGetValue(e.Result, out int value); + bins[index][e.Result] = ++value; + } } if (barDrawables != null) { for (int i = 0; i < barDrawables.Length; i++) { - barDrawables[i].UpdateOffset(bins[i]); + barDrawables[i].UpdateOffset(bins[i].Sum(b => b.Value)); } } else { - int maxCount = bins.Max(); - barDrawables = new Bar[total_timing_distribution_bins]; - - for (int i = 0; i < barDrawables.Length; i++) - barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index); + int maxCount = bins.Max(b => b.Values.Sum()); + barDrawables = bins.Select((bin, i) => new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index)).ToArray(); Container axisFlow; @@ -209,50 +206,102 @@ namespace osu.Game.Screens.Ranking.Statistics private class Bar : CompositeDrawable { - private readonly float value; + private readonly IReadOnlyList> values; private readonly float maxValue; + private readonly bool isCentre; + private readonly float totalValue; - private readonly Circle boxOriginal; - private Circle boxAdjustment; + private float basalHeight; + private float offsetAdjustment; - private const float minimum_height = 0.05f; + private Circle[] boxOriginals = null!; - public Bar(float value, float maxValue, bool isCentre) + private Circle? boxAdjustment; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private const double duration = 300; + + public Bar(IDictionary values, float maxValue, bool isCentre) { - this.value = value; + this.values = values.OrderBy(v => v.Key.GetIndexForOrderedDisplay()).ToList(); this.maxValue = maxValue; + this.isCentre = isCentre; + totalValue = values.Sum(v => v.Value); + offsetAdjustment = totalValue; RelativeSizeAxes = Axes.Both; Masking = true; + } - InternalChildren = new Drawable[] + [BackgroundDependencyLoader] + private void load() + { + if (values.Any()) { - boxOriginal = new Circle + boxOriginals = values.Select((v, i) => new Circle { RelativeSizeAxes = Axes.Both, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"), - Height = minimum_height, - }, - }; + Colour = isCentre && i == 0 ? Color4.White : colours.ForHitResult(v.Key), + Height = 0, + }).ToArray(); + // The bars of the stacked bar graph will be processed (stacked) from the bottom, which is the base position, + // to the top, and the bottom bar should be drawn more toward the front by design, + // while the drawing order is from the back to the front, so the order passed to `InternalChildren` is the opposite. + InternalChildren = boxOriginals.Reverse().ToArray(); + } + else + { + // A bin with no value draws a grey dot instead. + Circle dot = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = isCentre ? Color4.White : Color4.Gray, + Height = 0, + }; + InternalChildren = boxOriginals = new[] { dot }; + } } - private const double duration = 300; - protected override void LoadComplete() { base.LoadComplete(); - float height = Math.Clamp(value / maxValue, minimum_height, 1); + if (!values.Any()) + return; - if (height > minimum_height) - boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint); + updateBasalHeight(); + + foreach (var boxOriginal in boxOriginals) + { + boxOriginal.Y = 0; + boxOriginal.Height = basalHeight; + } + + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].MoveToY(offsetForValue(offsetValue) * BoundingBox.Height, duration, Easing.OutQuint); + boxOriginals[i].ResizeHeightTo(heightForValue(values[i].Value), duration, Easing.OutQuint); + offsetValue -= values[i].Value; + } + } + + protected override void Update() + { + base.Update(); + updateBasalHeight(); } public void UpdateOffset(float adjustment) { - bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height; + bool hasAdjustment = adjustment != totalValue; if (boxAdjustment == null) { @@ -271,7 +320,53 @@ namespace osu.Game.Screens.Ranking.Statistics }); } - boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint); + offsetAdjustment = adjustment; + drawAdjustmentBar(); + } + + private void updateBasalHeight() + { + float newBasalHeight = DrawHeight > DrawWidth ? DrawWidth / DrawHeight : 1; + + if (newBasalHeight == basalHeight) + return; + + basalHeight = newBasalHeight; + foreach (var dot in boxOriginals) + dot.Height = basalHeight; + + draw(); + } + + private float offsetForValue(float value) => (1 - basalHeight) * value / maxValue; + + private float heightForValue(float value) => MathF.Max(basalHeight + offsetForValue(value), 0); + + private void draw() + { + resizeBars(); + + if (boxAdjustment != null) + drawAdjustmentBar(); + } + + private void resizeBars() + { + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].Y = offsetForValue(offsetValue) * DrawHeight; + boxOriginals[i].Height = heightForValue(values[i].Value); + offsetValue -= values[i].Value; + } + } + + private void drawAdjustmentBar() + { + bool hasAdjustment = offsetAdjustment != totalValue; + + boxAdjustment.ResizeHeightTo(heightForValue(offsetAdjustment), duration, Easing.OutQuint); boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint); } } 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/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index ab68dec92d..79d7b99e51 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -8,7 +8,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -35,6 +38,10 @@ namespace osu.Game.Screens.Ranking.Statistics private readonly Container content; private readonly LoadingSpinner spinner; + private bool wasOpened; + private Sample popInSample; + private Sample popOutSample; + public StatisticsPanel() { InternalChild = new Container @@ -56,9 +63,12 @@ namespace osu.Game.Screens.Ranking.Statistics } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { Score.BindValueChanged(populateStatistics, true); + + popInSample = audio.Samples.Get(@"Results/statistics-panel-pop-in"); + popOutSample = audio.Samples.Get(@"Results/statistics-panel-pop-out"); } private CancellationTokenSource loadCancellation; @@ -81,18 +91,16 @@ namespace osu.Game.Screens.Ranking.Statistics spinner.Show(); var localCancellationSource = loadCancellation = new CancellationTokenSource(); - IBeatmap playableBeatmap = null; + + var workingBeatmap = beatmapManager.GetWorkingBeatmap(newScore.BeatmapInfo); // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. - Task.Run(() => - { - playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.BeatmapInfo).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods); - }, loadCancellation.Token).ContinueWith(_ => Schedule(() => + Task.Run(() => workingBeatmap.GetPlayableBeatmap(newScore.Ruleset, newScore.Mods), loadCancellation.Token).ContinueWith(task => Schedule(() => { bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticRows = newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); + var statisticRows = newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, task.GetResultSafely()); if (!hitEventsAvailable && statisticRows.SelectMany(r => r.Columns).All(c => c.RequiresHitEvents)) { @@ -216,9 +224,21 @@ namespace osu.Game.Screens.Ranking.Statistics return true; } - protected override void PopIn() => this.FadeIn(150, Easing.OutQuint); + protected override void PopIn() + { + this.FadeIn(150, Easing.OutQuint); - protected override void PopOut() => this.FadeOut(150, Easing.OutQuint); + popInSample?.Play(); + wasOpened = true; + } + + protected override void PopOut() + { + this.FadeOut(150, Easing.OutQuint); + + if (wasOpened) + popOutSample?.Play(); + } protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 72e6c7d159..a8cb06b888 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.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; using System.Collections.Generic; using System.Diagnostics; @@ -49,31 +47,31 @@ namespace osu.Game.Screens.Select /// /// Triggered when the loaded change and are completely loaded. /// - public Action BeatmapSetsChanged; + public Action? BeatmapSetsChanged; /// /// The currently selected beatmap. /// - public BeatmapInfo SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo; + public BeatmapInfo? SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo; - private CarouselBeatmap selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected); + private CarouselBeatmap? selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected); /// /// The currently selected beatmap set. /// - public BeatmapSetInfo SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; + public BeatmapSetInfo? SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; /// /// A function to optionally decide on a recommended difficulty from a beatmap set. /// - public Func, BeatmapInfo> GetRecommendedBeatmap; + public Func, BeatmapInfo>? GetRecommendedBeatmap; - private CarouselBeatmapSet selectedBeatmapSet; + private CarouselBeatmapSet? selectedBeatmapSet; /// /// Raised when the is changed. /// - public Action SelectionChanged; + public Action? SelectionChanged; public override bool HandleNonPositionalInput => AllowSelection; public override bool HandlePositionalInput => AllowSelection; @@ -102,7 +100,7 @@ namespace osu.Game.Screens.Select private readonly NoResultsPlaceholder noResultsPlaceholder; - private IEnumerable beatmapSets => root.Children.OfType(); + private IEnumerable beatmapSets => root.Items.OfType(); // todo: only used for testing, maybe remove. private bool loadedTestBeatmaps; @@ -121,7 +119,7 @@ namespace osu.Game.Screens.Select { CarouselRoot newRoot = new CarouselRoot(this); - newRoot.AddChildren(beatmapSets.Select(s => createCarouselSet(s.Detach())).Where(g => g != null)); + newRoot.AddItems(beatmapSets.Select(s => createCarouselSet(s.Detach())).Where(g => g != null)); root = newRoot; @@ -151,15 +149,15 @@ namespace osu.Game.Screens.Select private CarouselRoot root; - private IDisposable subscriptionSets; - private IDisposable subscriptionDeletedSets; - private IDisposable subscriptionBeatmaps; - private IDisposable subscriptionHiddenBeatmaps; + private IDisposable? subscriptionSets; + private IDisposable? subscriptionDeletedSets; + private IDisposable? subscriptionBeatmaps; + private IDisposable? subscriptionHiddenBeatmaps; private readonly DrawablePool setPool = new DrawablePool(100); - private Sample spinSample; - private Sample randomSelectSample; + private Sample? spinSample; + private Sample? randomSelectSample; private int visibleSetsCount; @@ -200,7 +198,7 @@ namespace osu.Game.Screens.Select } [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; protected override void LoadComplete() { @@ -215,7 +213,7 @@ namespace osu.Game.Screens.Select subscriptionHiddenBeatmaps = realm.RegisterForNotifications(r => r.All().Where(b => b.Hidden), beatmapsChanged); } - private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) { // If loading test beatmaps, avoid overwriting with realm subscription callbacks. if (loadedTestBeatmaps) @@ -228,7 +226,7 @@ namespace osu.Game.Screens.Select removeBeatmapSet(sender[i].ID); } - private void beatmapSetsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) { // If loading test beatmaps, avoid overwriting with realm subscription callbacks. if (loadedTestBeatmaps) @@ -265,9 +263,49 @@ namespace osu.Game.Screens.Select foreach (int i in changes.InsertedIndices) UpdateBeatmapSet(sender[i].Detach()); + + if (changes.DeletedIndices.Length > 0 && SelectedBeatmapInfo != null) + { + // If SelectedBeatmapInfo is non-null, the set should also be non-null. + Debug.Assert(SelectedBeatmapSet != null); + + // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. + // When an update occurs, the previous beatmap set is either soft or hard deleted. + // Check if the current selection was potentially deleted by re-querying its validity. + bool selectedSetMarkedDeleted = realm.Run(r => r.Find(SelectedBeatmapSet.ID))?.DeletePending != false; + + int[] modifiedAndInserted = changes.NewModifiedIndices.Concat(changes.InsertedIndices).ToArray(); + + if (selectedSetMarkedDeleted && modifiedAndInserted.Any()) + { + // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. + // This relies on the full update operation being in a single transaction, so please don't change that. + foreach (int i in modifiedAndInserted) + { + var beatmapSetInfo = sender[i]; + + foreach (var beatmapInfo in beatmapSetInfo.Beatmaps) + { + if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) + continue; + + // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. + if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) + { + SelectBeatmap(beatmapInfo); + return; + } + } + } + + // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. + // Let's attempt to follow set-level selection anyway. + SelectBeatmap(sender[modifiedAndInserted.First()].Beatmaps.First()); + } + } } - private void beatmapsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) { // we only care about actual changes in hidden status. if (changes == null) @@ -300,7 +338,7 @@ namespace osu.Game.Screens.Select if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSet)) return; - root.RemoveChild(existingSet); + root.RemoveItem(existingSet); itemsCache.Invalidate(); if (!Scroll.UserScrolling) @@ -316,16 +354,21 @@ namespace osu.Game.Screens.Select previouslySelectedID = selectedBeatmap?.BeatmapInfo.ID; var newSet = createCarouselSet(beatmapSet); + var removedSet = root.RemoveChild(beatmapSet.ID); - root.RemoveChild(beatmapSet.ID); + // If we don't remove this here, it may remain in a hidden state until scrolled off screen. + // Doesn't really affect anything during actual user interaction, but makes testing annoying. + var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); + if (removedDrawable != null) + expirePanelImmediately(removedDrawable); if (newSet != null) { - root.AddChild(newSet); + root.AddItem(newSet); // check if we can/need to maintain our current selection. if (previouslySelectedID != null) - select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); + select((CarouselItem?)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); } itemsCache.Invalidate(); @@ -342,7 +385,7 @@ namespace osu.Game.Screens.Select /// The beatmap to select. /// Whether to select the beatmap even if it is filtered (i.e., not visible on carousel). /// True if a selection was made, False if it wasn't. - public bool SelectBeatmap(BeatmapInfo beatmapInfo, bool bypassFilters = true) + public bool SelectBeatmap(BeatmapInfo? beatmapInfo, bool bypassFilters = true) { // ensure that any pending events from BeatmapManager have been run before attempting a selection. Scheduler.Update(); @@ -400,6 +443,9 @@ namespace osu.Game.Screens.Select private void selectNextSet(int direction, bool skipDifficulties) { + if (selectedBeatmap == null || selectedBeatmapSet == null) + return; + var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count]; @@ -412,10 +458,10 @@ namespace osu.Game.Screens.Select private void selectNextDifficulty(int direction) { - if (selectedBeatmap == null) + if (selectedBeatmap == null || selectedBeatmapSet == null) return; - var unfilteredDifficulties = selectedBeatmapSet.Children.Where(s => !s.Filtered.Value).ToList(); + var unfilteredDifficulties = selectedBeatmapSet.Items.Where(s => !s.Filtered.Value).ToList(); int index = unfilteredDifficulties.IndexOf(selectedBeatmap); @@ -441,7 +487,7 @@ namespace osu.Game.Screens.Select if (!visibleSets.Any()) return false; - if (selectedBeatmap != null) + if (selectedBeatmap != null && selectedBeatmapSet != null) { randomSelectedBeatmaps.Push(selectedBeatmap); @@ -484,11 +530,13 @@ namespace osu.Game.Screens.Select if (!beatmap.Filtered.Value) { - if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) - previouslyVisitedRandomSets.Remove(selectedBeatmapSet); - if (selectedBeatmapSet != null) + { + if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) + previouslyVisitedRandomSets.Remove(selectedBeatmapSet); + playSpinSample(distanceBetween(beatmap, selectedBeatmapSet)); + } select(beatmap); break; @@ -500,14 +548,18 @@ namespace osu.Game.Screens.Select private void playSpinSample(double distance) { - var chan = spinSample.GetChannel(); - chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount); - chan.Play(); + var chan = spinSample?.GetChannel(); + + if (chan != null) + { + chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount); + chan.Play(); + } randomSelectSample?.Play(); } - private void select(CarouselItem item) + private void select(CarouselItem? item) { if (!AllowSelection) return; @@ -519,7 +571,7 @@ namespace osu.Game.Screens.Select private FilterCriteria activeCriteria = new FilterCriteria(); - protected ScheduledDelegate PendingFilter; + protected ScheduledDelegate? PendingFilter; public bool AllowSelection = true; @@ -551,7 +603,7 @@ namespace osu.Game.Screens.Select } } - public void Filter(FilterCriteria newCriteria, bool debounce = true) + public void Filter(FilterCriteria? newCriteria, bool debounce = true) { if (newCriteria != null) activeCriteria = newCriteria; @@ -696,11 +748,7 @@ namespace osu.Game.Screens.Select // panel loaded as drawable but not required by visible range. // remove but only if too far off-screen if (panel.Y + panel.DrawHeight < visibleUpperBound - distance_offscreen_before_unload || panel.Y > visibleBottomBound + distance_offscreen_before_unload) - { - // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected). - panel.ClearTransforms(); - panel.Expire(); - } + expirePanelImmediately(panel); } // Add those items within the previously found index range that should be displayed. @@ -730,6 +778,13 @@ namespace osu.Game.Screens.Select } } + private static void expirePanelImmediately(DrawableCarouselItem panel) + { + // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected). + panel.ClearTransforms(); + panel.Expire(); + } + private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem(); private (int firstIndex, int lastIndex) getDisplayRange() @@ -751,7 +806,7 @@ namespace osu.Game.Screens.Select return (firstIndex, lastIndex); } - private CarouselBeatmapSet createCarouselSet(BeatmapSetInfo beatmapSet) + private CarouselBeatmapSet? createCarouselSet(BeatmapSetInfo beatmapSet) { // This can be moved to the realm query if required using: // .Filter("DeletePending == false && Protected == false && ANY Beatmaps.Hidden == false") @@ -798,7 +853,7 @@ namespace osu.Game.Screens.Select scrollTarget = null; - foreach (CarouselItem item in root.Children) + foreach (CarouselItem item in root.Items) { if (item.Filtered.Value) continue; @@ -917,7 +972,7 @@ namespace osu.Game.Screens.Select /// /// The item to be updated. /// For nested items, the parent of the item to be updated. - private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null) + private void updateItem(DrawableCarouselItem item, DrawableCarouselItem? parent = null) { Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); float itemDrawY = posInScroll.Y - visibleUpperBound; @@ -945,13 +1000,13 @@ namespace osu.Game.Screens.Select /// private class CarouselBoundsItem : CarouselItem { - public override DrawableCarouselItem CreateDrawableRepresentation() => - throw new NotImplementedException(); + public override DrawableCarouselItem CreateDrawableRepresentation() => throw new NotImplementedException(); } private class CarouselRoot : CarouselGroupEagerSelect { - private readonly BeatmapCarousel carousel; + // May only be null during construction (State.Value set causes PerformSelection to be triggered). + private readonly BeatmapCarousel? carousel; public readonly Dictionary BeatmapSetsByID = new Dictionary(); @@ -964,26 +1019,31 @@ namespace osu.Game.Screens.Select this.carousel = carousel; } - public override void AddChild(CarouselItem i) + public override void AddItem(CarouselItem i) { CarouselBeatmapSet set = (CarouselBeatmapSet)i; BeatmapSetsByID.Add(set.BeatmapSet.ID, set); - base.AddChild(i); + base.AddItem(i); } - public void RemoveChild(Guid beatmapSetID) + public CarouselBeatmapSet? RemoveChild(Guid beatmapSetID) { if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSet)) - RemoveChild(carouselBeatmapSet); + { + RemoveItem(carouselBeatmapSet); + return carouselBeatmapSet; + } + + return null; } - public override void RemoveChild(CarouselItem i) + public override void RemoveItem(CarouselItem i) { CarouselBeatmapSet set = (CarouselBeatmapSet)i; BeatmapSetsByID.Remove(set.BeatmapSet.ID); - base.RemoveChild(i); + base.RemoveItem(i); } protected override void PerformSelection() diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index 84426d230b..0996e3e202 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -1,43 +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 System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; namespace osu.Game.Screens.Select { - public class BeatmapClearScoresDialog : PopupDialog + public class BeatmapClearScoresDialog : DeleteConfirmationDialog { [Resolved] - private ScoreManager scoreManager { get; set; } + private ScoreManager scoreManager { get; set; } = null!; public BeatmapClearScoresDialog(BeatmapInfo beatmapInfo, Action onCompletion) { - BodyText = beatmapInfo.GetDisplayTitle(); - Icon = FontAwesome.Solid.Eraser; - HeaderText = @"Clearing all local scores. Are you sure?"; - Buttons = new PopupDialogButton[] + BodyText = $"All local scores on {beatmapInfo.GetDisplayTitle()}"; + DeleteAction = () => { - new PopupDialogOkButton - { - Text = @"Yes. Please.", - Action = () => - { - Task.Run(() => scoreManager.Delete(beatmapInfo)) - .ContinueWith(_ => onCompletion); - } - }, - new PopupDialogCancelButton - { - Text = @"No, I'm still attached.", - }, + Task.Run(() => scoreManager.Delete(beatmapInfo)) + .ContinueWith(_ => onCompletion); }; } } diff --git a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs index e3b83d5780..3d3e8b6d73 100644 --- a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs +++ b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs @@ -1,43 +1,26 @@ // 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.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public class BeatmapDeleteDialog : PopupDialog + public class BeatmapDeleteDialog : DeleteConfirmationDialog { - private BeatmapManager manager; + private readonly BeatmapSetInfo beatmapSet; + + public BeatmapDeleteDialog(BeatmapSetInfo beatmapSet) + { + this.beatmapSet = beatmapSet; + BodyText = $@"{beatmapSet.Metadata.Artist} - {beatmapSet.Metadata.Title}"; + } [BackgroundDependencyLoader] private void load(BeatmapManager beatmapManager) { - manager = beatmapManager; - } - - public BeatmapDeleteDialog(BeatmapSetInfo beatmap) - { - BodyText = $@"{beatmap.Metadata.Artist} - {beatmap.Metadata.Title}"; - - Icon = FontAwesome.Regular.TrashAlt; - HeaderText = @"Confirm deletion of"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = @"Yes. Totally. Delete it.", - Action = () => manager?.Delete(beatmap), - }, - new PopupDialogCancelButton - { - Text = @"Firetruck, I didn't mean to!", - }, - }; + DeleteAction = () => beatmapManager.Delete(beatmapSet); } } } diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 2fe62c4a4e..90418efe15 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.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 osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -38,15 +36,18 @@ namespace osu.Game.Screens.Select private readonly LoadingLayer loading; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - private IBeatmapInfo beatmapInfo; + [Resolved] + private SongSelect? songSelect { get; set; } - private APIFailTimes failTimes; + private IBeatmapInfo? beatmapInfo; - private int[] ratings; + private APIFailTimes? failTimes; - public IBeatmapInfo BeatmapInfo + private int[]? ratings; + + public IBeatmapInfo? BeatmapInfo { get => beatmapInfo; set @@ -56,7 +57,7 @@ namespace osu.Game.Screens.Select beatmapInfo = value; var onlineInfo = beatmapInfo as IBeatmapOnlineInfo; - var onlineSetInfo = beatmapInfo.BeatmapSet as IBeatmapSetOnlineInfo; + var onlineSetInfo = beatmapInfo?.BeatmapSet as IBeatmapSetOnlineInfo; failTimes = onlineInfo?.FailTimes; ratings = onlineSetInfo?.Ratings; @@ -140,9 +141,9 @@ namespace osu.Game.Screens.Select LayoutEasing = Easing.OutQuad, Children = new[] { - description = new MetadataSection(MetadataType.Description), - source = new MetadataSection(MetadataType.Source), - tags = new MetadataSection(MetadataType.Tags), + description = new MetadataSection(MetadataType.Description, searchOnSongSelect), + source = new MetadataSection(MetadataType.Source, searchOnSongSelect), + tags = new MetadataSection(MetadataType.Tags, searchOnSongSelect), }, }, }, @@ -175,6 +176,12 @@ namespace osu.Game.Screens.Select }, loading = new LoadingLayer(true) }; + + void searchOnSongSelect(string text) + { + if (songSelect != null) + songSelect.FilterControl.CurrentTextSearch.Value = text; + } } private void updateStatistics() diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index c57cbcfba4..03490ff37b 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -74,7 +74,7 @@ namespace osu.Game.Screens.Select.Carousel } if (match) - match &= criteria.Collection?.BeatmapHashes.Contains(BeatmapInfo.MD5Hash) ?? true; + match &= criteria.CollectionBeatmapMD5Hashes?.Contains(BeatmapInfo.MD5Hash) ?? true; if (match && criteria.RulesetCriteria != null) match &= criteria.RulesetCriteria.Matches(BeatmapInfo); diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 36ec536780..9a4319c6b2 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.Select.Carousel switch (State.Value) { case CarouselItemState.Selected: - return DrawableCarouselBeatmapSet.HEIGHT + Children.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT; + return DrawableCarouselBeatmapSet.HEIGHT + Items.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT; default: return DrawableCarouselBeatmapSet.HEIGHT; @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Select.Carousel } } - public IEnumerable Beatmaps => InternalChildren.OfType(); + public IEnumerable Beatmaps => Items.OfType(); public BeatmapSetInfo BeatmapSet; @@ -44,15 +44,15 @@ namespace osu.Game.Screens.Select.Carousel .OrderBy(b => b.Ruleset) .ThenBy(b => b.StarRating) .Select(b => new CarouselBeatmap(b)) - .ForEach(AddChild); + .ForEach(AddItem); } protected override CarouselItem GetNextToSelect() { if (LastSelected == null || LastSelected.Filtered.Value) { - if (GetRecommendedBeatmap?.Invoke(Children.OfType().Where(b => !b.Filtered.Value).Select(b => b.BeatmapInfo)) is BeatmapInfo recommended) - return Children.OfType().First(b => b.BeatmapInfo.Equals(recommended)); + if (GetRecommendedBeatmap?.Invoke(Items.OfType().Where(b => !b.Filtered.Value).Select(b => b.BeatmapInfo)) is BeatmapInfo recommended) + return Items.OfType().First(b => b.BeatmapInfo.Equals(recommended)); } return base.GetNextToSelect(); @@ -81,6 +81,16 @@ namespace osu.Game.Screens.Select.Carousel case SortMode.DateAdded: return otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); + case SortMode.DateRanked: + // Beatmaps which have no ranked date should already be filtered away in this mode. + if (BeatmapSet.DateRanked == null || otherSet.BeatmapSet.DateRanked == null) + return 0; + + return otherSet.BeatmapSet.DateRanked.Value.CompareTo(BeatmapSet.DateRanked.Value); + + case SortMode.LastPlayed: + return -compareUsingAggregateMax(otherSet, b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds()); + case SortMode.BPM: return compareUsingAggregateMax(otherSet, b => b.BPM); @@ -89,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); } } @@ -112,7 +129,13 @@ namespace osu.Game.Screens.Select.Carousel public override void Filter(FilterCriteria criteria) { base.Filter(criteria); - Filtered.Value = InternalChildren.All(i => i.Filtered.Value); + + bool filtered = Items.All(i => i.Filtered.Value); + + filtered |= criteria.Sort == SortMode.DateRanked && BeatmapSet?.DateRanked == null; + filtered |= criteria.Sort == SortMode.DateSubmitted && BeatmapSet?.DateSubmitted == null; + + Filtered.Value = filtered; } public override string ToString() => BeatmapSet.ToString(); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index 1cd9674b72..9302578038 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -2,64 +2,64 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; namespace osu.Game.Screens.Select.Carousel { /// - /// A group which ensures only one child is selected. + /// A group which ensures only one item is selected. /// public class CarouselGroup : CarouselItem { public override DrawableCarouselItem? CreateDrawableRepresentation() => null; - public IReadOnlyList Children => InternalChildren; + public IReadOnlyList Items => items; - protected List InternalChildren = new List(); + private readonly List items = new List(); /// - /// Used to assign a monotonically increasing ID to children as they are added. This member is - /// incremented whenever a child is added. + /// Used to assign a monotonically increasing ID to items as they are added. This member is + /// incremented whenever an item is added. /// - private ulong currentChildID; + private ulong currentItemID; private Comparer? criteriaComparer; - private FilterCriteria? lastCriteria; - public virtual void RemoveChild(CarouselItem i) + protected int GetIndexOfItem(CarouselItem lastSelected) => items.IndexOf(lastSelected); + + public virtual void RemoveItem(CarouselItem i) { - InternalChildren.Remove(i); + items.Remove(i); // it's important we do the deselection after removing, so any further actions based on // State.ValueChanged make decisions post-removal. i.State.Value = CarouselItemState.Collapsed; } - public virtual void AddChild(CarouselItem i) + public virtual void AddItem(CarouselItem i) { i.State.ValueChanged += state => ChildItemStateChanged(i, state.NewValue); - i.ChildID = ++currentChildID; + i.ItemID = ++currentItemID; if (lastCriteria != null) { i.Filter(lastCriteria); - int index = InternalChildren.BinarySearch(i, criteriaComparer); + int index = items.BinarySearch(i, criteriaComparer); if (index < 0) index = ~index; // BinarySearch hacks multiple return values with 2's complement. - InternalChildren.Insert(index, i); + items.Insert(index, i); } else { // criteria may be null for initial population. the filtering will be applied post-add. - InternalChildren.Add(i); + items.Add(i); } } public CarouselGroup(List? items = null) { - if (items != null) InternalChildren = items; + if (items != null) this.items = items; State.ValueChanged += state => { @@ -67,11 +67,11 @@ namespace osu.Game.Screens.Select.Carousel { case CarouselItemState.Collapsed: case CarouselItemState.NotSelected: - InternalChildren.ForEach(c => c.State.Value = CarouselItemState.Collapsed); + this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); break; case CarouselItemState.Selected: - InternalChildren.ForEach(c => + this.items.ForEach(c => { if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; }); @@ -84,11 +84,18 @@ namespace osu.Game.Screens.Select.Carousel { base.Filter(criteria); - InternalChildren.ForEach(c => c.Filter(criteria)); + items.ForEach(c => c.Filter(criteria)); - // IEnumerable.OrderBy() is used instead of List.Sort() to ensure sorting stability - criteriaComparer = Comparer.Create((x, y) => x.CompareTo(criteria, y)); - InternalChildren = InternalChildren.OrderBy(c => c, criteriaComparer).ToList(); + criteriaComparer = Comparer.Create((x, y) => + { + int comparison = x.CompareTo(criteria, y); + if (comparison != 0) + return comparison; + + return x.ItemID.CompareTo(y.ItemID); + }); + + items.Sort(criteriaComparer); lastCriteria = criteria; } @@ -98,7 +105,7 @@ namespace osu.Game.Screens.Select.Carousel // ensure we are the only item selected if (value == CarouselItemState.Selected) { - foreach (var b in InternalChildren) + foreach (var b in items) { if (item == b) continue; diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index 4805c7e2a4..61109829f3 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -10,7 +10,7 @@ using System.Linq; namespace osu.Game.Screens.Select.Carousel { /// - /// A group which ensures at least one child is selected (if the group itself is selected). + /// A group which ensures at least one item is selected (if the group itself is selected). /// public class CarouselGroupEagerSelect : CarouselGroup { @@ -35,46 +35,46 @@ namespace osu.Game.Screens.Select.Carousel /// /// To avoid overhead during filter operations, we don't attempt any selections until after all - /// children have been filtered. This bool will be true during the base + /// items have been filtered. This bool will be true during the base /// operation. /// - private bool filteringChildren; + private bool filteringItems; public override void Filter(FilterCriteria criteria) { - filteringChildren = true; + filteringItems = true; base.Filter(criteria); - filteringChildren = false; + filteringItems = false; attemptSelection(); } - public override void RemoveChild(CarouselItem i) + public override void RemoveItem(CarouselItem i) { - base.RemoveChild(i); + base.RemoveItem(i); if (i != LastSelected) updateSelectedIndex(); } - private bool addingChildren; + private bool addingItems; - public void AddChildren(IEnumerable items) + public void AddItems(IEnumerable items) { - addingChildren = true; + addingItems = true; foreach (var i in items) - AddChild(i); + AddItem(i); - addingChildren = false; + addingItems = false; attemptSelection(); } - public override void AddChild(CarouselItem i) + public override void AddItem(CarouselItem i) { - base.AddChild(i); - if (!addingChildren) + base.AddItem(i); + if (!addingItems) attemptSelection(); } @@ -97,21 +97,21 @@ namespace osu.Game.Screens.Select.Carousel private void attemptSelection() { - if (filteringChildren) return; + if (filteringItems) return; // we only perform eager selection if we are a currently selected group. if (State.Value != CarouselItemState.Selected) return; - // we only perform eager selection if none of our children are in a selected state already. - if (Children.Any(i => i.State.Value == CarouselItemState.Selected)) return; + // we only perform eager selection if none of our items are in a selected state already. + if (Items.Any(i => i.State.Value == CarouselItemState.Selected)) return; PerformSelection(); } protected virtual CarouselItem GetNextToSelect() { - return Children.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? - Children.Reverse().Skip(InternalChildren.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + return Items.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? + Items.Reverse().Skip(Items.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); } protected virtual void PerformSelection() @@ -131,6 +131,6 @@ namespace osu.Game.Screens.Select.Carousel updateSelectedIndex(); } - private void updateSelectedIndex() => lastSelectedIndex = LastSelected == null ? 0 : Math.Max(0, InternalChildren.IndexOf(LastSelected)); + private void updateSelectedIndex() => lastSelectedIndex = LastSelected == null ? 0 : Math.Max(0, GetIndexOfItem(LastSelected)); } } diff --git a/osu.Game/Screens/Select/Carousel/CarouselItem.cs b/osu.Game/Screens/Select/Carousel/CarouselItem.cs index a901f72dad..cbf079eb4b 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselItem.cs @@ -38,7 +38,7 @@ namespace osu.Game.Screens.Select.Carousel /// /// Used as a default sort method for s of differing types. /// - internal ulong ChildID; + internal ulong ItemID; /// /// Create a fresh drawable version of this item. @@ -49,7 +49,7 @@ namespace osu.Game.Screens.Select.Carousel { } - public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ChildID.CompareTo(other.ChildID); + public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ItemID.CompareTo(other.ItemID); public int CompareTo(CarouselItem other) => CarouselYPosition.CompareTo(other.CarouselYPosition); } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 1b3cab20e8..c3cb04680b 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -22,6 +22,7 @@ using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; @@ -63,12 +64,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } - [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } + [Resolved] + private RealmAccess realm { get; set; } + private IBindable starDifficultyBindable; private CancellationTokenSource starDifficultyCancellationSource; @@ -153,17 +154,12 @@ namespace osu.Game.Screens.Select.Carousel { Direction = FillDirection.Horizontal, Spacing = new Vector2(4, 0), + Scale = new Vector2(0.8f), AutoSizeAxes = Axes.Both, Children = new Drawable[] { - new TopLocalRank(beatmapInfo) - { - Scale = new Vector2(0.8f), - }, - starCounter = new StarCounter - { - Scale = new Vector2(0.8f), - } + new TopLocalRank(beatmapInfo), + starCounter = new StarCounter() } } } @@ -242,14 +238,11 @@ namespace osu.Game.Screens.Select.Carousel if (beatmapInfo.OnlineID > 0 && beatmapOverlay != null) items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmapInfo.OnlineID))); - if (collectionManager != null) - { - var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); - if (manageCollectionsDialog != null) - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + var collectionItems = realm.Realm.All().AsEnumerable().Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmapInfo)).Cast().ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); - } + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); if (hideRequested != null) items.Add(new OsuMenuItem(CommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo))); @@ -258,20 +251,6 @@ namespace osu.Game.Screens.Select.Carousel } } - private MenuItem createCollectionMenuItem(BeatmapCollection collection) - { - return new ToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => - { - if (s) - collection.BeatmapHashes.Add(beatmapInfo.MD5Hash); - else - collection.BeatmapHashes.Remove(beatmapInfo.MD5Hash); - }) - { - State = { Value = collection.BeatmapHashes.Contains(beatmapInfo.MD5Hash) } - }; - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index fc29f509ad..040f954bba 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -17,6 +17,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; @@ -32,12 +33,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private IDialogOverlay dialogOverlay { get; set; } - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } - [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } + [Resolved] + private RealmAccess realm { get; set; } + public IEnumerable DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Enumerable.Empty() : beatmapContainer.AliveChildren; [CanBeNull] @@ -152,7 +153,7 @@ namespace osu.Game.Screens.Select.Carousel { var carouselBeatmapSet = (CarouselBeatmapSet)Item; - var visibleBeatmaps = carouselBeatmapSet.Children.Where(c => c.Visible).ToArray(); + var visibleBeatmaps = carouselBeatmapSet.Items.Where(c => c.Visible).ToArray(); // if we are already displaying all the correct beatmaps, only run animation updates. // note that the displayed beatmaps may change due to the applied filter. @@ -223,14 +224,11 @@ namespace osu.Game.Screens.Select.Carousel if (beatmapSet.OnlineID > 0 && viewDetails != null) items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineID))); - if (collectionManager != null) - { - var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); - if (manageCollectionsDialog != null) - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + var collectionItems = realm.Realm.All().AsEnumerable().Select(createCollectionMenuItem).ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); - } + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); @@ -247,7 +245,7 @@ namespace osu.Game.Screens.Select.Carousel TernaryState state; - int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapHashes.Contains(b.MD5Hash)); + int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapMD5Hashes.Contains(b.MD5Hash)); if (countExisting == beatmapSet.Beatmaps.Count) state = TernaryState.True; @@ -256,24 +254,29 @@ namespace osu.Game.Screens.Select.Carousel else state = TernaryState.False; - return new TernaryStateToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => + var liveCollection = collection.ToLive(realm); + + return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => { - foreach (var b in beatmapSet.Beatmaps) + liveCollection.PerformWrite(c => { - switch (s) + foreach (var b in beatmapSet.Beatmaps) { - case TernaryState.True: - if (collection.BeatmapHashes.Contains(b.MD5Hash)) - continue; + switch (s) + { + case TernaryState.True: + if (c.BeatmapMD5Hashes.Contains(b.MD5Hash)) + continue; - collection.BeatmapHashes.Add(b.MD5Hash); - break; + c.BeatmapMD5Hashes.Add(b.MD5Hash); + break; - case TernaryState.False: - collection.BeatmapHashes.Remove(b.MD5Hash); - break; + case TernaryState.False: + c.BeatmapMD5Hashes.Remove(b.MD5Hash); + break; + } } - } + }); }) { State = { Value = state } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs index c92a0ac4ac..133bf5f9c3 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs @@ -53,7 +53,7 @@ namespace osu.Game.Screens.Select.Carousel if (item is CarouselGroup group) { - foreach (var c in group.Children) + foreach (var c in group.Items) c.Filtered.ValueChanged -= onStateChange; } } @@ -117,7 +117,7 @@ namespace osu.Game.Screens.Select.Carousel if (Item is CarouselGroup group) { - foreach (var c in group.Children) + foreach (var c in group.Items) c.Filtered.ValueChanged += onStateChange; } } diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index 577b3f5f64..a95d9078a2 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -61,14 +61,25 @@ namespace osu.Game.Screens.Select.Carousel Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Top = 5 }, - Children = new Drawable[] + Spacing = new Vector2(5), + Children = new[] { + beatmapSet.AllBeatmapsUpToDate + ? Empty() + : new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + new UpdateBeatmapSetButton(beatmapSet), + } + }, new BeatmapSetOnlineStatusPill { AutoSizeAxes = Axes.Both, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Right = 5 }, TextSize = 11, TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, Status = beatmapSet.Status @@ -76,6 +87,8 @@ namespace osu.Game.Screens.Select.Carousel new FillFlowContainer { AutoSizeAxes = Axes.Both, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, Spacing = new Vector2(3), ChildrenEnumerable = getDifficultyIcons(), }, diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs index cc01f61c57..0f000555d5 100644 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -1,13 +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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Models; @@ -20,27 +19,39 @@ using Realms; namespace osu.Game.Screens.Select.Carousel { - public class TopLocalRank : UpdateableRank + public class TopLocalRank : CompositeDrawable { private readonly BeatmapInfo beatmapInfo; [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } + private ScoreManager scoreManager { get; set; } = null!; - private IDisposable scoreSubscription; + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IDisposable? scoreSubscription; + + private readonly UpdateableRank updateable; + + public ScoreRank? DisplayedRank => updateable.Rank; public TopLocalRank(BeatmapInfo beatmapInfo) - : base(null) { this.beatmapInfo = beatmapInfo; - Size = new Vector2(40, 20); + AutoSizeAxes = Axes.Both; + + InternalChild = updateable = new UpdateableRank + { + Size = new Vector2(40, 20), + Alpha = 0, + }; } protected override void LoadComplete() @@ -55,23 +66,27 @@ namespace osu.Game.Screens.Select.Carousel .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1" + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2" - + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName) - .OrderByDescending(s => s.TotalScore), - (items, _, _) => - { - Rank = items.FirstOrDefault()?.Rank; - // Required since presence is changed via IsPresent override - Invalidate(Invalidation.Presence); - }); + + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName), + localScoresChanged); }, true); - } - public override bool IsPresent => base.IsPresent && Rank != null; + void localScoresChanged(IRealmCollection sender, ChangeSet? changes, Exception _) + { + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + + ScoreInfo? topScore = scoreManager.OrderByTotalScore(sender.Detach()).FirstOrDefault(); + + updateable.Rank = topScore?.Rank; + updateable.Alpha = topScore != null ? 1 : 0; + } + } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - scoreSubscription?.Dispose(); } } diff --git a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs new file mode 100644 index 0000000000..1bb607bcf3 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs @@ -0,0 +1,178 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Select.Carousel +{ + public class UpdateBeatmapSetButton : OsuAnimatedButton + { + private readonly BeatmapSetInfo beatmapSetInfo; + private SpriteIcon icon = null!; + private Box progressFill = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private LoginOverlay? loginOverlay { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + public UpdateBeatmapSetButton(BeatmapSetInfo beatmapSetInfo) + { + this.beatmapSetInfo = beatmapSetInfo; + + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + } + + private Bindable preferNoVideo = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + const float icon_size = 14; + + preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); + + Content.Anchor = Anchor.CentreLeft; + Content.Origin = Anchor.CentreLeft; + + Content.AddRange(new Drawable[] + { + progressFill = new Box + { + Colour = Color4.White, + Alpha = 0.2f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0, + }, + new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 5, Vertical = 3 }, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new Container + { + Size = new Vector2(icon_size), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.SyncAlt, + Size = new Vector2(icon_size), + }, + } + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Text = "Update", + } + } + }, + }); + + Action = updateBeatmap; + } + + private bool updateConfirmed; + + private void updateBeatmap() + { + if (!api.IsLoggedIn) + { + loginOverlay?.Show(); + return; + } + + if (dialogOverlay != null && beatmapSetInfo.Status == BeatmapOnlineStatus.LocallyModified && !updateConfirmed) + { + dialogOverlay.Push(new UpdateLocalConfirmationDialog(() => + { + updateConfirmed = true; + updateBeatmap(); + })); + + return; + } + + updateConfirmed = false; + + beatmapDownloader.DownloadAsUpdate(beatmapSetInfo, preferNoVideo.Value); + attachExistingDownload(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + icon.Spin(4000, RotationDirection.Clockwise); + } + + private void attachExistingDownload() + { + var download = beatmapDownloader.GetExistingDownload(beatmapSetInfo); + + if (download != null) + { + Enabled.Value = false; + TooltipText = string.Empty; + + download.DownloadProgressed += progress => progressFill.ResizeWidthTo(progress, 100, Easing.OutQuint); + download.Failure += _ => attachExistingDownload(); + } + else + { + Enabled.Value = true; + TooltipText = "Update beatmap with online changes"; + + progressFill.ResizeWidthTo(0, 100, Easing.OutQuint); + } + } + + protected override bool OnHover(HoverEvent e) + { + icon.Spin(400, RotationDirection.Clockwise); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + icon.Spin(4000, RotationDirection.Clockwise); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs b/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs new file mode 100644 index 0000000000..f5267e905e --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs @@ -0,0 +1,21 @@ +// 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.Graphics.Sprites; +using osu.Game.Overlays.Dialog; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Select.Carousel +{ + public class UpdateLocalConfirmationDialog : DeleteConfirmationDialog + { + public UpdateLocalConfirmationDialog(Action onConfirm) + { + HeaderText = PopupDialogStrings.UpdateLocallyModifiedText; + BodyText = PopupDialogStrings.UpdateLocallyModifiedDescription; + Icon = FontAwesome.Solid.ExclamationTriangle; + DeleteAction = onConfirm; + } + } +} diff --git a/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs b/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs index a82c969805..45e7ff4caa 100644 --- a/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs +++ b/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs @@ -65,6 +65,13 @@ namespace osu.Game.Screens.Select private class MinimumStarsSlider : StarsSlider { + public MinimumStarsSlider() + : base("0") + { + } + + public override LocalisableString TooltipText => Current.Value.ToString(@"0.## stars"); + protected override void LoadComplete() { base.LoadComplete(); @@ -82,6 +89,11 @@ namespace osu.Game.Screens.Select private class MaximumStarsSlider : StarsSlider { + public MaximumStarsSlider() + : base("∞") + { + } + protected override void LoadComplete() { base.LoadComplete(); @@ -96,10 +108,17 @@ namespace osu.Game.Screens.Select private class StarsSlider : OsuSliderBar { + private readonly string defaultString; + public override LocalisableString TooltipText => Current.IsDefault ? UserInterfaceStrings.NoLimit : Current.Value.ToString(@"0.## stars"); + protected StarsSlider(string defaultString) + { + this.defaultString = defaultString; + } + protected override bool OnHover(HoverEvent e) { base.OnHover(e); @@ -125,7 +144,7 @@ namespace osu.Game.Screens.Select Current.BindValueChanged(current => { - currentDisplay.Text = current.NewValue != Current.Default ? current.NewValue.ToString("N1") : "∞"; + currentDisplay.Text = current.NewValue != Current.Default ? current.NewValue.ToString("N1") : defaultString; }, true); } } diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs index 48f774393e..c77bdbfbc6 100644 --- a/osu.Game/Screens/Select/Filter/SortMode.cs +++ b/osu.Game/Screens/Select/Filter/SortMode.cs @@ -20,9 +20,18 @@ namespace osu.Game.Screens.Select.Filter [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))] BPM, + [Description("Date Submitted")] + DateSubmitted, + [Description("Date Added")] DateAdded, + [Description("Date Ranked")] + DateRanked, + + [Description("Last Played")] + LastPlayed, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] Difficulty, diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index a92b631100..ae82285fba 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -31,12 +31,18 @@ namespace osu.Game.Screens.Select public Action FilterChanged; + public Bindable CurrentTextSearch => searchTextBox.Current; + private OsuTabControl sortTabs; private Bindable sortMode; private Bindable groupMode; + private SeekLimitedSearchTextBox searchTextBox; + + private CollectionDropdown collectionDropdown; + public FilterCriteria CreateCriteria() { string query = searchTextBox.Text; @@ -47,7 +53,7 @@ namespace osu.Game.Screens.Select Sort = sortMode.Value, AllowConvertedBeatmaps = showConverted.Value, Ruleset = ruleset.Value, - Collection = collectionDropdown?.Current.Value?.Collection + CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes) }; if (!minimumStars.IsDefault) @@ -62,9 +68,6 @@ namespace osu.Game.Screens.Select return criteria; } - private SeekLimitedSearchTextBox searchTextBox; - private CollectionFilterDropdown collectionDropdown; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); @@ -112,42 +115,53 @@ namespace osu.Game.Screens.Select Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, }, - new FillFlowContainer + new GridContainer { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Direction = FillDirection.Horizontal, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(OsuTabControl.HORIZONTAL_SPACING, 0), - Children = new Drawable[] + ColumnDimensions = new[] { - new OsuTabControlCheckbox + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), + new Dimension(), + new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new[] { - Text = "Show converted", - Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - sortTabs = new OsuTabControl - { - RelativeSizeAxes = Axes.X, - Width = 0.5f, - Height = 24, - AutoSort = true, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AccentColour = colours.GreenLight, - Current = { BindTarget = sortMode } - }, - new OsuSpriteText - { - Text = SortStrings.Default, - Font = OsuFont.GetFont(size: 14), - Margin = new MarginPadding(5), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, + new OsuSpriteText + { + Text = SortStrings.Default, + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding(5), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + Empty(), + sortTabs = new OsuTabControl + { + RelativeSizeAxes = Axes.X, + Height = 24, + AutoSort = true, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AccentColour = colours.GreenLight, + Current = { BindTarget = sortMode } + }, + Empty(), + new OsuTabControlCheckbox + { + Text = "Show converted", + Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } } }, } @@ -165,10 +179,11 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.Both, Width = 0.48f, }, - collectionDropdown = new CollectionFilterDropdown + collectionDropdown = new CollectionDropdown { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + RequestFilter = updateCriteria, RelativeSizeAxes = Axes.X, Y = 4, Width = 0.5f, @@ -195,15 +210,6 @@ namespace osu.Game.Screens.Select groupMode.BindValueChanged(_ => updateCriteria()); sortMode.BindValueChanged(_ => updateCriteria()); - collectionDropdown.Current.ValueChanged += val => - { - if (val.NewValue == null) - // may be null briefly while menu is repopulated. - return; - - updateCriteria(); - }; - searchTextBox.Current.ValueChanged += _ => updateCriteria(); updateCriteria(); diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index c7e6e8496a..320bfb1b45 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -68,10 +68,10 @@ namespace osu.Game.Screens.Select } /// - /// The collection to filter beatmaps from. + /// Hashes from the to filter to. /// [CanBeNull] - public BeatmapCollection Collection; + public IEnumerable CollectionBeatmapMD5Hashes { get; set; } [CanBeNull] public IRulesetFilterCriteria RulesetCriteria { get; set; } diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 03b72bf5e9..c86554ddbc 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -1,9 +1,8 @@ // 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.Globalization; using System.Linq; using System.Text.RegularExpressions; @@ -123,10 +122,27 @@ namespace osu.Game.Screens.Select private static bool tryParseEnum(string value, out TEnum result) where TEnum : struct { - if (Enum.TryParse(value, true, out result)) return true; + // First try an exact match. + if (Enum.TryParse(value, true, out result)) + return true; - value = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture)); - return Enum.TryParse(value, true, out result); + // Then try a prefix match. + string? prefixMatch = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture)); + + if (prefixMatch == null) + return false; + + return Enum.TryParse(prefixMatch, true, out result); + } + + private static GroupCollection? tryMatchRegex(string value, string regex) + { + Match matches = Regex.Match(value, regex); + + if (matches.Success) + return matches.Groups; + + return null; } /// @@ -312,11 +328,45 @@ namespace osu.Game.Screens.Select private static bool tryUpdateLengthRange(FilterCriteria criteria, Operator op, string val) { - if (!tryParseDoubleWithPoint(val.TrimEnd('m', 's', 'h'), out double length)) + List parts = new List(); + + GroupCollection? match = null; + + match ??= tryMatchRegex(val, @"^((?\d+):)?(?\d+):(?\d+)$"); + match ??= tryMatchRegex(val, @"^((?\d+(\.\d+)?)h)?((?\d+(\.\d+)?)m)?((?\d+(\.\d+)?)s)?$"); + match ??= tryMatchRegex(val, @"^(?\d+(\.\d+)?)$"); + + if (match == null) return false; - int scale = getLengthScale(val); - return tryUpdateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0); + if (match["seconds"].Success) + parts.Add(match["seconds"].Value + "s"); + if (match["minutes"].Success) + parts.Add(match["minutes"].Value + "m"); + if (match["hours"].Success) + parts.Add(match["hours"].Value + "h"); + + double totalLength = 0; + int minScale = 3600000; + + for (int i = 0; i < parts.Count; i++) + { + string part = parts[i]; + string partNoUnit = part.TrimEnd('m', 's', 'h'); + if (!tryParseDoubleWithPoint(partNoUnit, out double length)) + return false; + + if (i != parts.Count - 1 && length >= 60) + return false; + if (i != 0 && partNoUnit.Contains('.')) + return false; + + int scale = getLengthScale(part); + totalLength += length * scale; + minScale = Math.Min(minScale, scale); + } + + return tryUpdateCriteriaRange(ref criteria.Length, op, totalLength, minScale / 2.0); } } } diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 2732b5baa8..21f81995be 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -9,9 +9,11 @@ using osu.Game.Screens.Play.HUD; using osu.Game.Rulesets.Mods; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -61,14 +63,28 @@ namespace osu.Game.Screens.Select Hotkey = GlobalAction.ToggleModSelection; } + [CanBeNull] + private ModSettingChangeTracker modSettingChangeTracker; + protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(_ => updateMultiplierText(), true); + Current.BindValueChanged(mods => + { + modSettingChangeTracker?.Dispose(); + + updateMultiplierText(); + + if (mods.NewValue != null) + { + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => updateMultiplierText(); + } + }, true); } - private void updateMultiplierText() + private void updateMultiplierText() => Schedule(() => { double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; @@ -85,6 +101,6 @@ namespace osu.Game.Screens.Select modDisplay.FadeIn(); else modDisplay.FadeOut(); - } + }); } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index 1f56915f62..aad7fdff39 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -138,7 +138,8 @@ namespace osu.Game.Screens.Select return false; } - TriggerClick(); + if (!e.Repeat) + TriggerClick(); return true; } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 43eaff56b3..161d4847bf 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.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 System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; @@ -27,11 +23,11 @@ namespace osu.Game.Screens.Select.Leaderboards { public class BeatmapLeaderboard : Leaderboard { - public Action ScoreSelected; + public Action? ScoreSelected; - private BeatmapInfo beatmapInfo; + private BeatmapInfo? beatmapInfo; - public BeatmapInfo BeatmapInfo + public BeatmapInfo? BeatmapInfo { get => beatmapInfo; set @@ -43,6 +39,11 @@ namespace osu.Game.Screens.Select.Leaderboards return; beatmapInfo = value; + + // Refetch is scheduled, which can cause scores to be outdated if the leaderboard is not currently updating. + // As scores are potentially used by other components, clear them eagerly to ensure a more correct state. + SetScores(null); + RefetchScores(); } } @@ -67,24 +68,26 @@ namespace osu.Game.Screens.Select.Leaderboards } [Resolved] - private ScoreManager scoreManager { get; set; } + private ScoreManager scoreManager { get; set; } = null!; [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [Resolved] - private IBindable> mods { get; set; } + private IBindable> mods { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; - private IDisposable scoreSubscription; + private IDisposable? scoreSubscription; + + private GetScoresRequest? scoreRetrievalRequest; [BackgroundDependencyLoader] private void load() @@ -99,10 +102,9 @@ namespace osu.Game.Screens.Select.Leaderboards protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; - protected override APIRequest FetchScores(CancellationToken cancellationToken) + protected override APIRequest? FetchScores(CancellationToken cancellationToken) { var fetchBeatmapInfo = BeatmapInfo; - var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; if (fetchBeatmapInfo == null) { @@ -110,13 +112,15 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } + var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; + if (Scope == BeatmapLeaderboardScope.Local) { subscribeToLocalScores(fetchBeatmapInfo, cancellationToken); return null; } - if (api?.IsLoggedIn != true) + if (!api.IsLoggedIn) { SetErrorState(LeaderboardState.NotLoggedIn); return null; @@ -140,7 +144,7 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - IReadOnlyList requestMods = null; + IReadOnlyList? requestMods = null; if (filterMods && !mods.Value.Any()) // add nomod for the request @@ -148,21 +152,14 @@ namespace osu.Game.Screens.Select.Leaderboards else if (filterMods) requestMods = mods.Value; - var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); + scoreRetrievalRequest = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); - req.Success += r => - { - scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets, fetchBeatmapInfo)).ToArray(), cancellationToken) - .ContinueWith(task => Schedule(() => - { - if (cancellationToken.IsCancellationRequested) - return; + scoreRetrievalRequest.Success += response => SetScores( + scoreManager.OrderByTotalScore(response.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))), + response.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo) + ); - SetScores(task.GetResultSafely(), r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo)); - }), TaskContinuationOptions.OnlyOnRanToCompletion); - }; - - return req; + return scoreRetrievalRequest; } protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope) @@ -188,7 +185,7 @@ namespace osu.Game.Screens.Select.Leaderboards + $" AND {nameof(ScoreInfo.DeletePending)} == false" , beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged); - void localScoresChanged(IRealmCollection sender, ChangeSet changes, Exception exception) + void localScoresChanged(IRealmCollection sender, ChangeSet? changes, Exception exception) { if (cancellationToken.IsCancellationRequested) return; @@ -213,23 +210,18 @@ namespace osu.Game.Screens.Select.Leaderboards scores = scores.Where(s => s.Mods.Any(m => selectedMods.Contains(m.Acronym))); } - scores = scores.Detach(); + scores = scoreManager.OrderByTotalScore(scores.Detach()); - scoreManager.OrderByTotalScoreAsync(scores.ToArray(), cancellationToken) - .ContinueWith(ordered => Schedule(() => - { - if (cancellationToken.IsCancellationRequested) - return; - - SetScores(ordered.GetResultSafely()); - }), TaskContinuationOptions.OnlyOnRanToCompletion); + SetScores(scores); } } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + scoreSubscription?.Dispose(); + scoreRetrievalRequest?.Cancel(); } } } diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index 3c05a1fa5a..d9312679bc 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.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.Allocation; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; @@ -12,44 +10,25 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Select { - public class LocalScoreDeleteDialog : PopupDialog + public class LocalScoreDeleteDialog : DeleteConfirmationDialog { private readonly ScoreInfo score; - [Resolved] - private ScoreManager scoreManager { get; set; } - - [Resolved] - private BeatmapManager beatmapManager { get; set; } - public LocalScoreDeleteDialog(ScoreInfo score) { this.score = score; - Debug.Assert(score != null); } [BackgroundDependencyLoader] - private void load() + private void load(BeatmapManager beatmapManager, ScoreManager scoreManager) { - BeatmapInfo beatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); + BeatmapInfo? beatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); Debug.Assert(beatmapInfo != null); BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; Icon = FontAwesome.Regular.TrashAlt; - HeaderText = "Confirm deletion of local score"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = "Yes. Please.", - Action = () => scoreManager?.Delete(score) - }, - new PopupDialogCancelButton - { - Text = "No, I'm still attached.", - }, - }; + DeleteAction = () => scoreManager.Delete(score); } } } diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs index b8b589ff99..73b53defe0 100644 --- a/osu.Game/Screens/Select/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.cs @@ -109,7 +109,7 @@ namespace osu.Game.Screens.Select textFlow.AddParagraph("No beatmaps found!"); textFlow.AddParagraph(string.Empty); - textFlow.AddParagraph("Consider using the \""); + textFlow.AddParagraph("- Consider running the \""); textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); textFlow.AddText("\" to download or import some beatmaps!"); } @@ -127,10 +127,10 @@ namespace osu.Game.Screens.Select config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1); }); - string lowerStar = filter.UserStarDifficulty.Min == null ? "∞" : $"{filter.UserStarDifficulty.Min:N1}"; + string lowerStar = $"{filter.UserStarDifficulty.Min ?? 0:N1}"; string upperStar = filter.UserStarDifficulty.Max == null ? "∞" : $"{filter.UserStarDifficulty.Max:N1}"; - textFlow.AddText($" the {lowerStar}-{upperStar} star difficulty filter."); + textFlow.AddText($" the {lowerStar} - {upperStar} star difficulty filter."); } // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). @@ -141,15 +141,14 @@ namespace osu.Game.Screens.Select textFlow.AddLink(" enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); textFlow.AddText("automatic conversion!"); } - - if (!string.IsNullOrEmpty(filter?.SearchText)) - { - textFlow.AddParagraph("- Try "); - textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); - textFlow.AddText($" for \"{filter.SearchText}\"."); - } } + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + textFlow.AddParagraph("- Try "); + textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); + textFlow.AddText($" for \"{filter.SearchText}\"."); + } // TODO: add clickable link to reset criteria. } } diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index c24ca9a7cf..94e4215175 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.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 osu.Framework.Allocation; @@ -24,27 +22,38 @@ namespace osu.Game.Screens.Select { public class PlaySongSelect : SongSelect { - private OsuScreen playerLoader; + private OsuScreen? playerLoader; [Resolved(CanBeNull = true)] - private INotificationOverlay notifications { get; set; } + private INotificationOverlay? notifications { get; set; } public override bool AllowExternalScreenChange => true; protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); + private PlayBeatmapDetailArea playBeatmapDetailArea = null!; + [BackgroundDependencyLoader] private void load(OsuColour colours) { BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); - - ((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore; } protected void PresentScore(ScoreInfo score) => FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false))); - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + protected override BeatmapDetailArea CreateBeatmapDetailArea() + { + playBeatmapDetailArea = new PlayBeatmapDetailArea + { + Leaderboard = + { + ScoreSelected = PresentScore + } + }; + + return playBeatmapDetailArea; + } protected override bool OnKeyDown(KeyDownEvent e) { @@ -61,9 +70,9 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } - private IReadOnlyList modsAtGameplayStart; + private IReadOnlyList? modsAtGameplayStart; - private ModAutoplay getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); + private ModAutoplay? getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); protected override bool OnStart() { @@ -100,14 +109,26 @@ namespace osu.Game.Screens.Select Player createPlayer() { + Player player; + var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); if (replayGeneratingMod != null) { - return new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)); + player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)) + { + LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } + }; + } + else + { + player = new SoloPlayer + { + LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } + }; } - return new SoloPlayer(); + return player; } } diff --git a/osu.Game/Screens/Select/SkinDeleteDialog.cs b/osu.Game/Screens/Select/SkinDeleteDialog.cs index 8defd6347a..c701d9049f 100644 --- a/osu.Game/Screens/Select/SkinDeleteDialog.cs +++ b/osu.Game/Screens/Select/SkinDeleteDialog.cs @@ -1,43 +1,29 @@ // 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.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Skinning; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public class SkinDeleteDialog : PopupDialog + public class SkinDeleteDialog : DeleteConfirmationDialog { - [Resolved] - private SkinManager manager { get; set; } + private readonly Skin skin; public SkinDeleteDialog(Skin skin) { + this.skin = skin; BodyText = skin.SkinInfo.Value.Name; - Icon = FontAwesome.Regular.TrashAlt; - HeaderText = @"Confirm deletion of"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = @"Yes. Totally. Delete it.", - Action = () => - { - if (manager == null) - return; + } - manager.Delete(skin.SkinInfo.Value); - manager.CurrentSkinInfo.SetDefault(); - }, - }, - new PopupDialogCancelButton - { - Text = @"Firetruck, I didn't mean to!", - }, + [BackgroundDependencyLoader] + private void load(SkinManager manager) + { + DeleteAction = () => + { + manager.Delete(skin.SkinInfo.Value); + manager.CurrentSkinInfo.SetDefault(); }; } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 7abcbfca42..ece94b5365 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -313,7 +313,7 @@ namespace osu.Game.Screens.Select (new FooterButtonOptions(), BeatmapOptions) }; - protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(); + protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { @@ -405,20 +405,21 @@ namespace osu.Game.Screens.Select private ScheduledDelegate selectionChangedDebounce; - private void workingBeatmapChanged(ValueChangedEvent e) + private void updateCarouselSelection(ValueChangedEvent e = null) { - if (e.NewValue is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; + var beatmap = e?.NewValue ?? Beatmap.Value; + if (beatmap is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; - Logger.Log($"Song select working beatmap updated to {e.NewValue}"); + Logger.Log($"Song select working beatmap updated to {beatmap}"); - if (!Carousel.SelectBeatmap(e.NewValue.BeatmapInfo, false)) + if (!Carousel.SelectBeatmap(beatmap.BeatmapInfo, false)) { // A selection may not have been possible with filters applied. // There was possibly a ruleset mismatch. This is a case we can help things along by updating the game-wide ruleset to match. - if (!e.NewValue.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) + if (!beatmap.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) { - Ruleset.Value = e.NewValue.BeatmapInfo.Ruleset; + Ruleset.Value = beatmap.BeatmapInfo.Ruleset; transferRulesetValue(); } @@ -426,10 +427,10 @@ namespace osu.Game.Screens.Select // we still want to temporarily show the new beatmap, bypassing filters. // This will be undone the next time the user changes the filter. var criteria = FilterControl.CreateCriteria(); - criteria.SelectedBeatmapSet = e.NewValue.BeatmapInfo.BeatmapSet; + criteria.SelectedBeatmapSet = beatmap.BeatmapInfo.BeatmapSet; Carousel.Filter(criteria); - Carousel.SelectBeatmap(e.NewValue.BeatmapInfo); + Carousel.SelectBeatmap(beatmap.BeatmapInfo); } } @@ -597,6 +598,8 @@ namespace osu.Game.Screens.Select if (Beatmap != null && !Beatmap.Value.BeatmapSetInfo.DeletePending) { + updateCarouselSelection(); + updateComponentFromBeatmap(Beatmap.Value); if (ControlGlobalMusic) @@ -680,7 +683,7 @@ namespace osu.Game.Screens.Select } private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) - => beatmap.PrepareTrackForPreviewLooping(); + => beatmap.PrepareTrackForPreview(true); public override bool OnBackButton() { @@ -805,7 +808,7 @@ namespace osu.Game.Screens.Select }; decoupledRuleset.DisabledChanged += r => Ruleset.Disabled = r; - Beatmap.BindValueChanged(workingBeatmapChanged); + Beatmap.BindValueChanged(updateCarouselSelection); boundLocalBindables = true; } @@ -924,5 +927,10 @@ namespace osu.Game.Screens.Select return base.OnHover(e); } } + + internal class SoloModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + } } } diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 0c1fe56eb6..259ac0160d 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -115,14 +115,9 @@ namespace osu.Game.Screens.Spectate { case NotifyDictionaryChangedAction.Add: case NotifyDictionaryChangedAction.Replace: - foreach ((int userId, var state) in e.NewItems.AsNonNull()) + foreach ((int userId, SpectatorState state) in e.NewItems.AsNonNull()) onUserStateChanged(userId, state); break; - - case NotifyDictionaryChangedAction.Remove: - foreach ((int userId, SpectatorState state) in e.OldItems.AsNonNull()) - onUserStateRemoved(userId, state); - break; } } @@ -136,33 +131,21 @@ namespace osu.Game.Screens.Spectate switch (newState.State) { - case SpectatedUserState.Passed: - // Make sure that gameplay completes to the end. - if (gameplayStates.TryGetValue(userId, out var gameplayState)) - gameplayState.Score.Replay.HasReceivedAllFrames = true; - break; - case SpectatedUserState.Playing: Schedule(() => OnNewPlayingUserState(userId, newState)); startGameplay(userId); break; + + case SpectatedUserState.Passed: + markReceivedAllFrames(userId); + break; + + case SpectatedUserState.Quit: + quitGameplay(userId); + break; } } - private void onUserStateRemoved(int userId, SpectatorState state) - { - if (!userMap.ContainsKey(userId)) - return; - - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; - - gameplayState.Score.Replay.HasReceivedAllFrames = true; - - gameplayStates.Remove(userId); - Schedule(() => EndGameplay(userId, state)); - } - private void startGameplay(int userId) { Debug.Assert(userMap.ContainsKey(userId)); @@ -196,6 +179,29 @@ namespace osu.Game.Screens.Spectate Schedule(() => StartGameplay(userId, gameplayState)); } + /// + /// Marks an existing gameplay session as received all frames. + /// + private void markReceivedAllFrames(int userId) + { + if (gameplayStates.TryGetValue(userId, out var gameplayState)) + gameplayState.Score.Replay.HasReceivedAllFrames = true; + } + + private void quitGameplay(int userId) + { + if (!userMap.ContainsKey(userId)) + return; + + if (!gameplayStates.ContainsKey(userId)) + return; + + markReceivedAllFrames(userId); + + gameplayStates.Remove(userId); + Schedule(() => QuitGameplay(userId)); + } + /// /// Invoked when a spectated user's state has changed to a new state indicating the player is currently playing. /// @@ -211,11 +217,10 @@ namespace osu.Game.Screens.Spectate protected abstract void StartGameplay(int userId, [NotNull] SpectatorGameplayState spectatorGameplayState); /// - /// Ends gameplay for a user. + /// Quits gameplay for a user. /// - /// The user to end gameplay for. - /// The final user state. - protected abstract void EndGameplay(int userId, SpectatorState state); + /// The user to quit gameplay for. + protected abstract void QuitGameplay(int userId); /// /// Stops spectating a user. @@ -223,10 +228,10 @@ namespace osu.Game.Screens.Spectate /// The user to stop spectating. protected void RemoveUser(int userId) { - if (!userStates.TryGetValue(userId, out var state)) + if (!userStates.ContainsKey(userId)) return; - onUserStateRemoved(userId, state); + quitGameplay(userId); users.Remove(userId); userMap.Remove(userId); diff --git a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs index c9d4dc7811..bacaccd68e 100644 --- a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs +++ b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs @@ -261,8 +261,8 @@ namespace osu.Game.Screens.Utility string exclusive = "unknown"; - if (host.Window is WindowsWindow windowsWindow) - exclusive = windowsWindow.FullscreenCapability.ToString(); + if (host.Renderer is IWindowsRenderer windowsRenderer) + exclusive = windowsRenderer.FullscreenCapability.ToString(); statusText.Clear(); diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs new file mode 100644 index 0000000000..010e2175e1 --- /dev/null +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -0,0 +1,220 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class ArgonSkin : Skin + { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = osu.Game.Skinning.SkinInfo.ARGON_SKIN, + Name = "osu! \"argon\" (2022)", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(ArgonSkin).GetInvariantInstantiationInfo() + }; + + private readonly IStorageResourceProvider resources; + + public ArgonSkin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources) + : base(skin, resources) + { + this.resources = resources; + + Configuration.CustomComboColours = new List + { + // Standard combo progression order is green - blue - red - yellow. + // But for whatever reason, this starts from index 1, not 0. + // + // We've added two new combo colours in argon, so to ensure the initial rotation matches, + // this same progression is in slots 1 - 4. + + // Orange + new Color4(241, 116, 0, 255), + // Green + new Color4(0, 241, 53, 255), + // Blue + new Color4(0, 82, 241, 255), + // Red + new Color4(241, 0, 0, 255), + // Yellow + new Color4(232, 235, 0, 255), + // Purple + new Color4(92, 0, 241, 255), + }; + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); + + public override ISample GetSample(ISampleInfo sampleInfo) + { + foreach (string lookup in sampleInfo.LookupNames) + { + var sample = Samples?.Get(lookup) ?? resources.AudioManager?.Samples.Get(lookup); + if (sample != null) + return sample; + } + + return null; + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (base.GetDrawableComponent(component) is Drawable c) + return c; + + switch (component) + { + case SkinnableTargetComponent target: + switch (target.Target) + { + case SkinnableTarget.SongSelect: + var songSelectComponents = new SkinnableTargetComponentsContainer(_ => + { + // do stuff when we need to. + }); + + return songSelectComponents; + + case SkinnableTarget.MainHUDComponents: + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => + { + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + var combo = container.OfType().FirstOrDefault(); + var ppCounter = container.OfType().FirstOrDefault(); + + if (score != null) + { + score.Anchor = Anchor.TopCentre; + score.Origin = Anchor.TopCentre; + + // elements default to beneath the health bar + const float vertical_offset = 30; + + const float horizontal_padding = 20; + + score.Position = new Vector2(0, vertical_offset); + + if (ppCounter != null) + { + ppCounter.Y = score.Position.Y + ppCounter.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).Y - 4; + ppCounter.Origin = Anchor.TopCentre; + ppCounter.Anchor = Anchor.TopCentre; + } + + if (accuracy != null) + { + accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); + accuracy.Origin = Anchor.TopRight; + accuracy.Anchor = Anchor.TopCentre; + + if (combo != null) + { + combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); + combo.Anchor = Anchor.TopCentre; + } + } + + var hitError = container.OfType().FirstOrDefault(); + + if (hitError != null) + { + hitError.Anchor = Anchor.CentreLeft; + hitError.Origin = Anchor.CentreLeft; + } + + var hitError2 = container.OfType().LastOrDefault(); + + if (hitError2 != null) + { + hitError2.Anchor = Anchor.CentreRight; + hitError2.Scale = new Vector2(-1, 1); + // origin flipped to match scale above. + hitError2.Origin = Anchor.CentreLeft; + } + } + }) + { + Children = new Drawable[] + { + new DefaultComboCounter(), + new DefaultScoreCounter(), + new DefaultAccuracyCounter(), + new DefaultHealthDisplay(), + new DefaultSongProgress(), + new BarHitErrorMeter(), + new BarHitErrorMeter(), + new PerformancePointsCounter() + } + }; + + return skinnableTargetWrapper; + } + + return null; + } + + switch (component.LookupName) + { + // Temporary until default skin has a valid hit lighting. + case @"lighting": + return Drawable.Empty(); + } + + if (GetTexture(component.LookupName) is Texture t) + return new Sprite { Texture = t }; + + return null; + } + + public override IBindable GetConfig(TLookup lookup) + { + // todo: this code is pulled from LegacySkin and should not exist. + // will likely change based on how databased storage of skin configuration goes. + switch (lookup) + { + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + return SkinUtils.As(new Bindable>(Configuration.ComboColours)); + } + + break; + + case SkinComboColourLookup comboColour: + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); + } + + return null; + } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours[colourIndex % source.ComboColours.Count]; + } +} diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index abc7b61036..f0caef9fa1 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -67,9 +67,12 @@ namespace osu.Game.Skinning return sampleInfo is StoryboardSampleInfo || beatmapHitsounds.Value; } + private readonly ISkin skin; + public BeatmapSkinProvidingContainer(ISkin skin) : base(skin) { + this.skin = skin; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -84,11 +87,21 @@ namespace osu.Game.Skinning } [BackgroundDependencyLoader] - private void load() + private void load(SkinManager skins) { beatmapSkins.BindValueChanged(_ => TriggerSourceChanged()); beatmapColours.BindValueChanged(_ => TriggerSourceChanged()); beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); + + // If the beatmap skin looks to have skinnable resources, add the default classic skin as a fallback opportunity. + if (skin is LegacySkinTransformer legacySkin && legacySkin.IsProvidingLegacyResources) + { + SetSources(new[] + { + skin, + skins.DefaultClassicSkin + }); + } } } } diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index 2ebcc98c53..b80275a1e8 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -17,7 +17,7 @@ namespace osu.Game.Skinning public static SkinInfo CreateInfo() => new SkinInfo { ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. - Name = "osu!classic", + Name = "osu! \"classic\" (2013)", Creator = "team osu!", Protected = true, InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() @@ -46,6 +46,8 @@ namespace osu.Game.Skinning new Color4(242, 24, 57, 255) }; + Configuration.ConfigDictionary[nameof(SkinConfiguration.LegacySetting.AllowSliderBallTint)] = @"true"; + Configuration.LegacyVersion = 2.7m; } } diff --git a/osu.Game/Skinning/EFSkinInfo.cs b/osu.Game/Skinning/EFSkinInfo.cs deleted file mode 100644 index 4204364c50..0000000000 --- a/osu.Game/Skinning/EFSkinInfo.cs +++ /dev/null @@ -1,65 +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 System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Database; -using osu.Game.Extensions; -using osu.Game.IO; - -namespace osu.Game.Skinning -{ - [Table(@"SkinInfo")] - public class EFSkinInfo : IHasFiles, IEquatable, IHasPrimaryKey, ISoftDelete - { - internal const int DEFAULT_SKIN = 0; - internal const int CLASSIC_SKIN = -1; - internal const int RANDOM_SKIN = -2; - - public int ID { get; set; } - - public string Name { get; set; } = string.Empty; - - public string Creator { get; set; } = string.Empty; - - public string Hash { get; set; } - - public string InstantiationInfo { get; set; } - - public virtual Skin CreateInstance(IStorageResourceProvider resources) - { - var type = string.IsNullOrEmpty(InstantiationInfo) - // handle the case of skins imported before InstantiationInfo was added. - ? typeof(LegacySkin) - : Type.GetType(InstantiationInfo).AsNonNull(); - - return (Skin)Activator.CreateInstance(type, this, resources); - } - - public List Files { get; set; } = new List(); - - public bool DeletePending { get; set; } - - public static EFSkinInfo Default { get; } = new EFSkinInfo - { - ID = DEFAULT_SKIN, - Name = "osu! (triangles)", - Creator = "team osu!", - InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() - }; - - public bool Equals(EFSkinInfo other) => other != null && ID == other.ID; - - public override string ToString() - { - string author = Creator == null ? string.Empty : $"({Creator})"; - return $"{Name} {author}".Trim(); - } - - public bool IsManaged => ID > 0; - } -} diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs index 46f5c1e67f..2937b62eec 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -11,9 +11,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Screens; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; namespace osu.Game.Skinning.Editor { @@ -90,6 +93,52 @@ namespace osu.Game.Skinning.Editor base.AddBlueprintFor(item); } + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Left: + moveSelection(new Vector2(-1, 0)); + return true; + + case Key.Right: + moveSelection(new Vector2(1, 0)); + return true; + + case Key.Up: + moveSelection(new Vector2(0, -1)); + return true; + + case Key.Down: + moveSelection(new Vector2(0, 1)); + return true; + } + + return false; + } + + protected override void SelectAll() + { + SelectedItems.AddRange(targetComponents.SelectMany(list => list).Except(SelectedItems).ToArray()); + } + + /// + /// Move the current selection spatially by the specified delta, in screen coordinates (ie. the same coordinates as the blueprints). + /// + /// + private void moveSelection(Vector2 delta) + { + var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); + + if (firstBlueprint == null) + return; + + // convert to game space coordinates + delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); + + SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); + } + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index 344a659627..469657c03c 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -1,11 +1,8 @@ // 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.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,24 +13,25 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Screens.Edit.Components; +using osu.Game.Screens.Play.HUD; using osuTK; namespace osu.Game.Skinning.Editor { public class SkinComponentToolbox : EditorSidebarSection { - public Action RequestPlacement; + public Action? RequestPlacement; - private readonly CompositeDrawable target; + private readonly CompositeDrawable? target; - public SkinComponentToolbox(CompositeDrawable target = null) + private FillFlowContainer fill = null!; + + public SkinComponentToolbox(CompositeDrawable? target = null) : base("Components") { this.target = target; } - private FillFlowContainer fill; - [BackgroundDependencyLoader] private void load() { @@ -52,12 +50,7 @@ namespace osu.Game.Skinning.Editor { fill.Clear(); - var skinnableTypes = typeof(OsuGame).Assembly.GetTypes() - .Where(t => !t.IsInterface && !t.IsAbstract) - .Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)) - .OrderBy(t => t.Name) - .ToArray(); - + var skinnableTypes = SkinnableInfo.GetAllAvailableDrawables(); foreach (var type in skinnableTypes) attemptAddComponent(type); } @@ -90,21 +83,17 @@ namespace osu.Game.Skinning.Editor public class ToolboxComponentButton : OsuButton { - protected override bool ShouldBeConsideredForInput(Drawable child) => false; - - public override bool PropagateNonPositionalInputSubTree => false; + public Action? RequestPlacement; private readonly Drawable component; - private readonly CompositeDrawable dependencySource; + private readonly CompositeDrawable? dependencySource; - public Action RequestPlacement; - - private Container innerContainer; + private Container innerContainer = null!; private const float contracted_size = 60; private const float expanded_size = 120; - public ToolboxComponentButton(Drawable component, CompositeDrawable dependencySource) + public ToolboxComponentButton(Drawable component, CompositeDrawable? dependencySource) { this.component = component; this.dependencySource = dependencySource; @@ -184,9 +173,13 @@ namespace osu.Game.Skinning.Editor public class DependencyBorrowingContainer : Container { - private readonly CompositeDrawable donor; + protected override bool ShouldBeConsideredForInput(Drawable child) => false; - public DependencyBorrowingContainer(CompositeDrawable donor) + public override bool PropagateNonPositionalInputSubTree => false; + + private readonly CompositeDrawable? donor; + + public DependencyBorrowingContainer(CompositeDrawable? donor) { this.donor = donor; } diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 649b63dda4..c1ff161f25 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -13,21 +13,26 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; +using osu.Game.Overlays.OSD; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Menus; namespace osu.Game.Skinning.Editor { [Cached(typeof(SkinEditor))] - public class SkinEditor : VisibilityContainer, ICanAcceptFiles + public class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler { public const double TRANSITION_DURATION = 500; @@ -68,6 +73,9 @@ namespace osu.Game.Skinning.Editor private EditorSidebar componentsSidebar; private EditorSidebar settingsSidebar; + [Resolved(canBeNull: true)] + private OnScreenDisplay onScreenDisplay { get; set; } + public SkinEditor() { } @@ -199,6 +207,25 @@ namespace osu.Game.Skinning.Editor SelectedComponents.BindCollectionChanged((_, _) => Scheduler.AddOnce(populateSettings), true); } + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case PlatformAction.Save: + if (e.Repeat) + return false; + + Save(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + public void UpdateTargetScreen(Drawable targetScreen) { this.targetScreen = targetScreen; @@ -316,6 +343,7 @@ namespace osu.Game.Skinning.Editor currentSkin.Value.UpdateDrawableTarget(t); skins.Save(skins.CurrentSkin.Value); + onScreenDisplay?.Display(new SkinEditorToast(ToastStrings.SkinSaved, currentSkin.Value.SkinInfo.ToString())); } protected override bool OnHover(HoverEvent e) => true; @@ -395,5 +423,13 @@ namespace osu.Game.Skinning.Editor game?.UnregisterImportHandler(this); } + + private class SkinEditorToast : Toast + { + public SkinEditorToast(LocalisableString value, string skinDisplayName) + : base(SkinSettingsStrings.SkinLayoutEditor, value, skinDisplayName) + { + } + } } } diff --git a/osu.Game/Skinning/HUDSkinComponents.cs b/osu.Game/Skinning/HUDSkinComponents.cs deleted file mode 100644 index 586882d790..0000000000 --- a/osu.Game/Skinning/HUDSkinComponents.cs +++ /dev/null @@ -1,18 +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 - -namespace osu.Game.Skinning -{ - public enum HUDSkinComponents - { - ComboCounter, - ScoreCounter, - AccuracyCounter, - HealthDisplay, - SongProgress, - BarHitErrorMeter, - ColourHitErrorMeter, - } -} diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs index dcb9e9bbb9..a65d15d24b 100644 --- a/osu.Game/Skinning/IAnimationTimeReference.cs +++ b/osu.Game/Skinning/IAnimationTimeReference.cs @@ -5,7 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; using osu.Framework.Timing; namespace osu.Game.Skinning diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index 431fc1c0b8..c093dc2f32 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -4,7 +4,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; diff --git a/osu.Game/Skinning/ISkinTransformer.cs b/osu.Game/Skinning/ISkinTransformer.cs new file mode 100644 index 0000000000..f985b8afcd --- /dev/null +++ b/osu.Game/Skinning/ISkinTransformer.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Skinning +{ + /// + /// A skin transformer takes in an and applies transformations to it. + /// The most common use case is allowing individual rulesets to add skinnable components without directly coupling to underlying skins. + /// + public interface ISkinTransformer : ISkin + { + /// + /// The original skin that is being transformed. + /// + ISkin Skin { get; } + } +} diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 43ae0a2252..e8414b4c11 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -96,7 +96,7 @@ namespace osu.Game.Skinning new SkinInfo { Name = beatmapInfo.ToString(), - Creator = beatmapInfo.Metadata.Author.Username ?? string.Empty + Creator = beatmapInfo.Metadata.Author.Username }; } } diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Skinning/LegacyComboCounter.cs similarity index 99% rename from osu.Game/Screens/Play/HUD/LegacyComboCounter.cs rename to osu.Game/Skinning/LegacyComboCounter.cs index 6e36ffb54e..bfa6d5a255 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Skinning/LegacyComboCounter.cs @@ -9,10 +9,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; +using osu.Game.Screens.Play.HUD; using osuTK; -namespace osu.Game.Screens.Play.HUD +namespace osu.Game.Skinning { /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. diff --git a/osu.Game/Skinning/LegacyJudgementPieceNew.cs b/osu.Game/Skinning/LegacyJudgementPieceNew.cs index b87fc9acb5..2cb055d8ba 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceNew.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceNew.cs @@ -56,7 +56,7 @@ namespace osu.Game.Skinning if (result != HitResult.Miss) { //new judgement shows old as a temporary effect - AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f) + AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f, true) { Blending = BlendingParameters.Additive, Anchor = Anchor.Centre, diff --git a/osu.Game/Skinning/LegacyJudgementPieceOld.cs b/osu.Game/Skinning/LegacyJudgementPieceOld.cs index c47f46ed2b..3f4d13c082 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceOld.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceOld.cs @@ -18,11 +18,13 @@ namespace osu.Game.Skinning private readonly HitResult result; private readonly float finalScale; + private readonly bool forceTransforms; - public LegacyJudgementPieceOld(HitResult result, Func createMainDrawable, float finalScale = 1f) + public LegacyJudgementPieceOld(HitResult result, Func createMainDrawable, float finalScale = 1f, bool forceTransforms = false) { this.result = result; this.finalScale = finalScale; + this.forceTransforms = forceTransforms; AutoSizeAxes = Axes.Both; Origin = Anchor.Centre; @@ -43,8 +45,8 @@ namespace osu.Game.Skinning this.FadeInFromZero(fade_in_length); this.Delay(fade_out_delay).FadeOut(fade_out_length); - // legacy judgements don't play any transforms if they are an animation. - if (animation?.FrameCount > 1) + // legacy judgements don't play any transforms if they are an animation.... UNLESS they are the temporary displayed judgement from new piece. + if (animation?.FrameCount > 1 && !forceTransforms) return; switch (result) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs b/osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs similarity index 81% rename from osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs rename to osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs index 152ed5c3d9..2bcdd5b5a1 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs +++ b/osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs @@ -7,15 +7,15 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; -namespace osu.Game.Rulesets.Osu.Skinning.Legacy +namespace osu.Game.Skinning { - internal class KiaiFlashingDrawable : BeatSyncedContainer + public class LegacyKiaiFlashingDrawable : BeatSyncedContainer { private readonly Drawable flashingDrawable; - private const float flash_opacity = 0.3f; + private const float flash_opacity = 0.55f; - public KiaiFlashingDrawable(Func creationFunc) + public LegacyKiaiFlashingDrawable(Func creationFunc) { AutoSizeAxes = Axes.Both; @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy flashingDrawable .FadeTo(flash_opacity) .Then() - .FadeOut(timingPoint.BeatLength * 0.75f); + .FadeOut(Math.Max(80, timingPoint.BeatLength - 80), Easing.OutSine); } } } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index 45454be4a5..3ec0ee6006 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -1,21 +1,34 @@ // 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.Skinning { + /// + /// This class exists for the explicit purpose of ferrying information from ManiaBeatmap in a way LegacySkin can use it. + /// This is because half of the mania legacy skin implementation is in LegacySkin (osu.Game project) which doesn't have visibility + /// over ManiaBeatmap / StageDefinition. + /// public class LegacyManiaSkinConfigurationLookup { - public readonly int Keys; - public readonly LegacyManiaSkinConfigurationLookups Lookup; - public readonly int? TargetColumn; + /// + /// Total columns across all stages. + /// + public readonly int TotalColumns; - public LegacyManiaSkinConfigurationLookup(int keys, LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + /// + /// The column which is being looked up. + /// May be null if the configuration does not apply to a specific column. + /// Note that this is the absolute index across all stages. + /// + public readonly int? ColumnIndex; + + public readonly LegacyManiaSkinConfigurationLookups Lookup; + + public LegacyManiaSkinConfigurationLookup(int totalColumns, LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) { - Keys = keys; + TotalColumns = totalColumns; Lookup = lookup; - TargetColumn = targetColumn; + ColumnIndex = columnIndex; } } @@ -29,6 +42,8 @@ namespace osu.Game.Skinning HitPosition, ScorePosition, LightPosition, + StagePaddingTop, + StagePaddingBottom, HitTargetImage, ShowJudgementLine, KeyImage, diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 49914c53aa..0aafdd4db0 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -121,7 +121,7 @@ namespace osu.Game.Skinning break; case string when pair.Key.StartsWith("Colour", StringComparison.Ordinal): - HandleColours(currentConfig, line); + HandleColours(currentConfig, line, true); break; // Custom sprite paths diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 34219722a1..eaca0de11a 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -10,16 +10,15 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osuTK.Graphics; @@ -129,18 +128,18 @@ namespace osu.Game.Skinning private IBindable? lookupForMania(LegacyManiaSkinConfigurationLookup maniaLookup) { - if (!maniaConfigurations.TryGetValue(maniaLookup.Keys, out var existing)) - maniaConfigurations[maniaLookup.Keys] = existing = new LegacyManiaSkinConfiguration(maniaLookup.Keys); + if (!maniaConfigurations.TryGetValue(maniaLookup.TotalColumns, out var existing)) + maniaConfigurations[maniaLookup.TotalColumns] = existing = new LegacyManiaSkinConfiguration(maniaLookup.TotalColumns); switch (maniaLookup.Lookup) { case LegacyManiaSkinConfigurationLookups.ColumnWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); @@ -158,15 +157,15 @@ namespace osu.Game.Skinning return SkinUtils.As(getManiaImage(existing, "LightingN")); case LegacyManiaSkinConfigurationLookups.ExplosionScale: - Debug.Assert(maniaLookup.TargetColumn != null); + Debug.Assert(maniaLookup.ColumnIndex != null); if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); - if (existing.ExplosionWidth[maniaLookup.TargetColumn.Value] != 0) - return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.ColumnLineColour: return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); @@ -175,53 +174,53 @@ namespace osu.Game.Skinning return SkinUtils.As(getCustomColour(existing, "ColourJudgementLine")); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.TargetColumn + 1}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.ColumnIndex + 1}")); case LegacyManiaSkinConfigurationLookups.ColumnLightColour: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.TargetColumn + 1}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.ColumnIndex + 1}")); case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); case LegacyManiaSkinConfigurationLookups.NoteImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}")); case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}H")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}H")); case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}T")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}T")); case LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}L")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}L")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: return SkinUtils.As(getManiaImage(existing, "LightingL")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: - Debug.Assert(maniaLookup.TargetColumn != null); + Debug.Assert(maniaLookup.ColumnIndex != null); if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); - if (existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] != 0) - return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.KeyImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}")); case LegacyManiaSkinConfigurationLookups.KeyImageDown: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}D")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}D")); case LegacyManiaSkinConfigurationLookups.LeftStageImage: return SkinUtils.As(getManiaImage(existing, "StageLeft")); @@ -239,12 +238,12 @@ namespace osu.Game.Skinning return SkinUtils.As(getManiaImage(existing, "StageHint")); case LegacyManiaSkinConfigurationLookups.LeftLineWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.RightLineWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value + 1])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); case LegacyManiaSkinConfigurationLookups.Hit0: case LegacyManiaSkinConfigurationLookups.Hit50: @@ -337,14 +336,21 @@ namespace osu.Game.Skinning { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); - var combo = container.OfType().FirstOrDefault(); if (score != null && accuracy != null) { accuracy.Y = container.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; } - var songProgress = container.OfType().FirstOrDefault(); + var songProgress = container.OfType().FirstOrDefault(); + + if (songProgress != null && accuracy != null) + { + songProgress.Anchor = Anchor.TopRight; + songProgress.Origin = Anchor.CentreRight; + songProgress.X = -accuracy.ScreenSpaceDeltaToParentSpace(accuracy.ScreenSpaceDrawQuad.Size).X - 10; + songProgress.Y = container.ToLocalSpace(accuracy.ScreenSpaceDrawQuad.TopLeft).Y + (accuracy.ScreenSpaceDeltaToParentSpace(accuracy.ScreenSpaceDrawQuad.Size).Y / 2); + } var hitError = container.OfType().FirstOrDefault(); @@ -354,12 +360,6 @@ namespace osu.Game.Skinning hitError.Origin = Anchor.CentreLeft; hitError.Rotation = -90; } - - if (songProgress != null) - { - if (hitError != null) hitError.Y -= SongProgress.MAX_HEIGHT; - if (combo != null) combo.Y -= SongProgress.MAX_HEIGHT; - } }) { Children = new Drawable[] @@ -368,7 +368,7 @@ namespace osu.Game.Skinning new LegacyScoreCounter(), new LegacyAccuracyCounter(), new LegacyHealthDisplay(), - new SongProgress(), + new LegacySongProgress(), new BarHitErrorMeter(), } }; @@ -389,18 +389,17 @@ namespace osu.Game.Skinning if (particle != null) return new LegacyJudgementPieceNew(resultComponent.Component, createDrawable, particle); - else - return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); + + return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); } return null; case SkinnableSprite.SpriteComponent sprite: return this.GetAnimation(sprite.LookupName, false, false); - - default: - throw new UnsupportedSkinComponentException(component); } + + return null; } private Texture? getParticleTexture(HitResult result) diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index e5f87b3230..11c21d432f 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -48,7 +48,7 @@ namespace osu.Game.Skinning // osu!catch section only has colour settings // so no harm in handling the entire section case Section.CatchTheBeat: - HandleColours(skin, line); + HandleColours(skin, line, true); return; } diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index 5a537dd57d..8765882af2 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -9,7 +9,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using static osu.Game.Skinning.SkinConfiguration; diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index da7da759fc..367e5bae01 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -1,15 +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 - -using System; -using JetBrains.Annotations; using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; -using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Rulesets.Objects.Legacy; using static osu.Game.Skinning.SkinConfiguration; @@ -19,27 +11,19 @@ namespace osu.Game.Skinning /// /// Transformer used to handle support of legacy features for individual rulesets. /// - public abstract class LegacySkinTransformer : ISkin + public abstract class LegacySkinTransformer : SkinTransformer { /// - /// The which is being transformed. + /// Whether the skin being transformed is able to provide legacy resources for the ruleset. /// - [NotNull] - public ISkin Skin { get; } + public virtual bool IsProvidingLegacyResources => this.HasFont(LegacyFont.Combo); - protected LegacySkinTransformer([NotNull] ISkin skin) + protected LegacySkinTransformer(ISkin skin) + : base(skin) { - Skin = skin ?? throw new ArgumentNullException(nameof(skin)); } - public virtual Drawable GetDrawableComponent(ISkinComponent component) => Skin.GetDrawableComponent(component); - - public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); - - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) - => Skin.GetTexture(componentName, wrapModeS, wrapModeT); - - public virtual ISample GetSample(ISampleInfo sampleInfo) + public override ISample? GetSample(ISampleInfo sampleInfo) { if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) return Skin.GetSample(sampleInfo); @@ -48,9 +32,7 @@ namespace osu.Game.Skinning if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) return new SampleVirtual(); - return Skin.GetSample(sampleInfo); + return base.GetSample(sampleInfo); } - - public virtual IBindable GetConfig(TLookup lookup) => Skin.GetConfig(lookup); } } diff --git a/osu.Game/Skinning/LegacySongProgress.cs b/osu.Game/Skinning/LegacySongProgress.cs new file mode 100644 index 0000000000..f828e301f2 --- /dev/null +++ b/osu.Game/Skinning/LegacySongProgress.cs @@ -0,0 +1,87 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning +{ + public class LegacySongProgress : SongProgress + { + private CircularProgress circularProgress = null!; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(33); + + InternalChildren = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.92f), + Child = circularProgress = new CircularProgress + { + RelativeSizeAxes = Axes.Both, + }, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderColour = Colour4.White, + BorderThickness = 2, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + } + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Colour4.White, + Size = new Vector2(4), + } + }; + } + + protected override void PopIn() + { + this.FadeIn(500, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(100); + } + + protected override void UpdateProgress(double progress, bool isIntro) + { + if (isIntro) + { + circularProgress.Scale = new Vector2(-1, 1); + circularProgress.Anchor = Anchor.TopRight; + circularProgress.Colour = new Colour4(199, 255, 47, 153); + circularProgress.Current.Value = 1 - progress; + } + else + { + circularProgress.Scale = new Vector2(1); + circularProgress.Anchor = Anchor.TopLeft; + circularProgress.Colour = new Colour4(255, 255, 255, 153); + circularProgress.Current.Value = progress; + } + } + } +} diff --git a/osu.Game/Skinning/ResourceStoreBackedSkin.cs b/osu.Game/Skinning/ResourceStoreBackedSkin.cs index 597eef99d4..efdbb0a207 100644 --- a/osu.Game/Skinning/ResourceStoreBackedSkin.cs +++ b/osu.Game/Skinning/ResourceStoreBackedSkin.cs @@ -6,7 +6,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -24,7 +23,7 @@ namespace osu.Game.Skinning public ResourceStoreBackedSkin(IResourceStore resources, GameHost host, AudioManager audio) { - textures = new TextureStore(host.CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + textures = new TextureStore(host.Renderer, host.CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); samples = audio.GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); } diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index 937cf20bc6..7267ebd92d 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -41,7 +41,7 @@ namespace osu.Game.Skinning Ruleset = ruleset; Beatmap = beatmap; - InternalChild = new BeatmapSkinProvidingContainer(beatmapSkin is LegacySkin ? GetLegacyRulesetTransformedSkin(beatmapSkin) : beatmapSkin) + InternalChild = new BeatmapSkinProvidingContainer(GetRulesetTransformedSkin(beatmapSkin)) { Child = Content = new Container { @@ -67,21 +67,22 @@ namespace osu.Game.Skinning Debug.Assert(ParentSource != null); - foreach (var skin in ParentSource.AllSources) + foreach (var source in ParentSource.AllSources) { - switch (skin) + switch (source) { - case LegacySkin legacySkin: - sources.Add(GetLegacyRulesetTransformedSkin(legacySkin)); + case Skin skin: + sources.Add(GetRulesetTransformedSkin(skin)); break; default: - sources.Add(skin); + sources.Add(source); break; } } - int lastDefaultSkinIndex = sources.IndexOf(sources.OfType().LastOrDefault()); + // TODO: check + int lastDefaultSkinIndex = sources.IndexOf(sources.OfType().LastOrDefault()); // Ruleset resources should be given the ability to override game-wide defaults // This is achieved by placing them before the last instance of DefaultSkin. @@ -94,16 +95,16 @@ namespace osu.Game.Skinning SetSources(sources); } - protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin) + protected ISkin GetRulesetTransformedSkin(ISkin skin) { - if (legacySkin == null) + if (skin == null) return null; - var rulesetTransformed = Ruleset.CreateLegacySkinProvider(legacySkin, Beatmap); + var rulesetTransformed = Ruleset.CreateSkinTransformer(skin, Beatmap); if (rulesetTransformed != null) return rulesetTransformed; - return legacySkin; + return skin; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index d6aa9cdaad..dd43e4dc54 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -11,7 +11,6 @@ using Newtonsoft.Json; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Logging; @@ -69,14 +68,16 @@ namespace osu.Game.Skinning storage ??= realmBackedStorage = new RealmBackedResourceStore(SkinInfo, resources.Files, resources.RealmAccess); - (storage as ResourceStore)?.AddExtension("ogg"); - var samples = resources.AudioManager?.GetSampleStore(storage); if (samples != null) samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + // osu-stable performs audio lookups in order of wav -> mp3 -> ogg. + // The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering. + (storage as ResourceStore)?.AddExtension("ogg"); + Samples = samples; - Textures = new TextureStore(resources.CreateTextureLoaderStore(storage)); + Textures = new TextureStore(resources.Renderer, resources.CreateTextureLoaderStore(storage)); } else { @@ -108,6 +109,13 @@ namespace osu.Game.Skinning try { string jsonContent = Encoding.UTF8.GetString(bytes); + + // handle namespace changes... + + // can be removed 2023-01-31 + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); if (deserializedContent == null) diff --git a/osu.Game/Skinning/SkinConfiguration.cs b/osu.Game/Skinning/SkinConfiguration.cs index 0b1159f8fd..4e5d96ccb8 100644 --- a/osu.Game/Skinning/SkinConfiguration.cs +++ b/osu.Game/Skinning/SkinConfiguration.cs @@ -38,7 +38,8 @@ namespace osu.Game.Skinning HitCirclePrefix, HitCircleOverlap, AnimationFramerate, - LayeredHitSounds + LayeredHitSounds, + AllowSliderBallTint, } public static List DefaultComboColours { get; } = new List diff --git a/osu.Game/Skinning/SkinFileInfo.cs b/osu.Game/Skinning/SkinFileInfo.cs deleted file mode 100644 index f3243d3f24..0000000000 --- a/osu.Game/Skinning/SkinFileInfo.cs +++ /dev/null @@ -1,31 +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.ComponentModel.DataAnnotations; -using osu.Game.Database; -using osu.Game.IO; - -namespace osu.Game.Skinning -{ - public class SkinFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int SkinInfoID { get; set; } - - public EFSkinInfo SkinInfo { get; set; } - - public int FileInfoID { get; set; } - - public FileInfo FileInfo { get; set; } - - [Required] - public string Filename { get; set; } - - IFileInfo INamedFileUsage.File => FileInfo; - } -} diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index ed31a8c3f5..701dcdfc2d 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -10,6 +10,7 @@ using System.Threading; using Newtonsoft.Json; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; @@ -49,7 +50,7 @@ namespace osu.Game.Skinning protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) { - var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file); + var skinInfoFile = model.GetFile(skin_info_file); if (skinInfoFile != null) { @@ -129,7 +130,7 @@ namespace osu.Game.Skinning authorLine, }; - var existingFile = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + var existingFile = item.GetFile(@"skin.ini"); if (existingFile == null) { @@ -163,7 +164,7 @@ namespace osu.Game.Skinning { Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); - var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + var existingIni = item.GetFile(@"skin.ini"); if (existingIni != null) item.Files.Remove(existingIni); @@ -231,6 +232,9 @@ namespace osu.Game.Skinning { skin.SkinInfo.PerformWrite(s => { + // Update for safety + s.InstantiationInfo = skin.GetType().GetInvariantInstantiationInfo(); + // Serialise out the SkinInfo itself. string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented }); @@ -248,7 +252,7 @@ namespace osu.Game.Skinning { string filename = @$"{drawableInfo.Key}.json"; - var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename); + var oldFile = s.GetFile(filename); if (oldFile != null) modelManager.ReplaceFile(oldFile, streamContent, s.Realm); diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index bf3cf77257..d051149155 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO; @@ -19,7 +18,8 @@ namespace osu.Game.Skinning [JsonObject(MemberSerialization.OptIn)] public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles { - internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD"); + internal static readonly Guid TRIANGLES_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD"); + internal static readonly Guid ARGON_SKIN = new Guid("CFFA69DE-B3E3-4DEE-8563-3C4F425C05D0"); internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187"); internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908"); @@ -45,7 +45,16 @@ namespace osu.Game.Skinning var type = string.IsNullOrEmpty(InstantiationInfo) // handle the case of skins imported before InstantiationInfo was added. ? typeof(LegacySkin) - : Type.GetType(InstantiationInfo).AsNonNull(); + : Type.GetType(InstantiationInfo); + + if (type == null) + { + // Since the class was renamed from "DefaultSkin" to "TrianglesSkin", the type retrieval would fail + // for user modified skins. This aims to amicably handle that. + // If we ever add more default skins in the future this will need some kind of proper migration rather than + // a single fallback. + return new TrianglesSkin(this, resources); + } return (Skin)Activator.CreateInstance(type, this, resources); } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index dc0197e613..0e66278fc0 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -14,7 +14,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -39,6 +39,11 @@ namespace osu.Game.Skinning [ExcludeFromDynamicCompile] public class SkinManager : ModelManager, ISkinSource, IStorageResourceProvider, IModelImporter { + /// + /// The default "classic" skin. + /// + public Skin DefaultClassicSkin { get; } + private readonly AudioManager audio; private readonly Scheduler scheduler; @@ -49,24 +54,15 @@ namespace osu.Game.Skinning public readonly Bindable CurrentSkin = new Bindable(); - public readonly Bindable> CurrentSkinInfo = new Bindable>(Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()) - { - Default = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged() - }; + public readonly Bindable> CurrentSkinInfo = new Bindable>(ArgonSkin.CreateInfo().ToLiveUnmanaged()); private readonly SkinImporter skinImporter; private readonly IResourceStore userFiles; - /// - /// The default skin. - /// - public Skin DefaultSkin { get; } + private Skin argonSkin { get; } - /// - /// The default legacy skin. - /// - public Skin DefaultLegacySkin { get; } + private Skin trianglesSkin { get; } public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) : base(storage, realm) @@ -85,8 +81,9 @@ namespace osu.Game.Skinning var defaultSkins = new[] { - DefaultLegacySkin = new DefaultLegacySkin(this), - DefaultSkin = new DefaultSkin(this), + DefaultClassicSkin = new DefaultLegacySkin(this), + trianglesSkin = new TrianglesSkin(this), + argonSkin = new ArgonSkin(this), }; // Ensure the default entries are present. @@ -104,7 +101,7 @@ namespace osu.Game.Skinning CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); }; - CurrentSkin.Value = DefaultSkin; + CurrentSkin.Value = argonSkin; CurrentSkin.ValueChanged += skin => { if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value)) @@ -119,11 +116,13 @@ namespace osu.Game.Skinning Realm.Run(r => { // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = r.All().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); + var randomChoices = r.All() + .Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID) + .ToArray(); if (randomChoices.Length == 0) { - CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged(); + CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged(); return; } @@ -227,11 +226,15 @@ namespace osu.Game.Skinning { yield return CurrentSkin.Value; - if (CurrentSkin.Value is LegacySkin && CurrentSkin.Value != DefaultLegacySkin) - yield return DefaultLegacySkin; + // Skin manager provides default fallbacks. + // This handles cases where a user skin doesn't have the required resources for complete display of + // certain elements. - if (CurrentSkin.Value != DefaultSkin) - yield return DefaultSkin; + if (CurrentSkin.Value is LegacySkin && CurrentSkin.Value != DefaultClassicSkin) + yield return DefaultClassicSkin; + + if (CurrentSkin.Value != trianglesSkin) + yield return trianglesSkin; } } @@ -249,6 +252,7 @@ namespace osu.Game.Skinning #region IResourceStorageProvider + IRenderer IStorageResourceProvider.Renderer => host.Renderer; AudioManager IStorageResourceProvider.AudioManager => audio; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.Files => userFiles; @@ -272,6 +276,8 @@ namespace osu.Game.Skinning public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) => skinImporter.Import(notification, tasks); + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => skinImporter.ImportAsUpdate(notification, task, original); + public Task> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) => skinImporter.Import(task, batchImport, cancellationToken); #endregion @@ -289,10 +295,26 @@ namespace osu.Game.Skinning Guid currentUserSkin = CurrentSkinInfo.Value.ID; if (items.Any(s => s.ID == currentUserSkin)) - scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()); + scheduler.Add(() => CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged()); Delete(items.ToList(), silent); }); } + + public void SetSkinFromConfiguration(string guidString) + { + Live skinInfo = null; + + if (Guid.TryParse(guidString, out var guid)) + skinInfo = Query(s => s.ID == guid); + + if (skinInfo == null) + { + if (guid == SkinInfo.CLASSIC_SKIN) + skinInfo = DefaultClassicSkin.SkinInfo; + } + + CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo; + } } } diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index e447570931..42c39e581f 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -12,7 +12,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; diff --git a/osu.Game/Skinning/SkinTransformer.cs b/osu.Game/Skinning/SkinTransformer.cs new file mode 100644 index 0000000000..4da60f1e43 --- /dev/null +++ b/osu.Game/Skinning/SkinTransformer.cs @@ -0,0 +1,32 @@ +// 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.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; + +namespace osu.Game.Skinning +{ + public abstract class SkinTransformer : ISkinTransformer + { + public ISkin Skin { get; } + + protected SkinTransformer(ISkin skin) + { + Skin = skin ?? throw new ArgumentNullException(nameof(skin)); + } + + public virtual Drawable? GetDrawableComponent(ISkinComponent component) => Skin.GetDrawableComponent(component); + + public virtual Texture? GetTexture(string componentName) => GetTexture(componentName, default, default); + + public virtual Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Skin.GetTexture(componentName, wrapModeS, wrapModeT); + + public virtual ISample? GetSample(ISampleInfo sampleInfo) => Skin.GetSample(sampleInfo); + + public virtual IBindable? GetConfig(TLookup lookup) where TLookup : notnull where TValue : notnull => Skin.GetConfig(lookup); + } +} diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index c2696c56f3..8f71b40801 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -151,7 +151,7 @@ namespace osu.Game.Skinning bool wasPlaying = IsPlaying; // Remove all pooled samples (return them to the pool), and dispose the rest. - samplesContainer.RemoveAll(s => s.IsInPool); + samplesContainer.RemoveAll(s => s.IsInPool, false); samplesContainer.Clear(); foreach (var s in samples) diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 57beb6e803..5a39121b16 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -100,7 +100,7 @@ namespace osu.Game.Skinning { foreach (var skin in skins) { - if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin)) + if (skin is ISkinTransformer transformer && isUserSkin(transformer.Skin)) return transformer.Skin; if (isUserSkin(skin)) @@ -112,7 +112,8 @@ namespace osu.Game.Skinning // Temporarily used to exclude undesirable ISkin implementations static bool isUserSkin(ISkin skin) - => skin.GetType() == typeof(DefaultSkin) + => skin.GetType() == typeof(TrianglesSkin) + || skin.GetType() == typeof(ArgonSkin) || skin.GetType() == typeof(DefaultLegacySkin) || skin.GetType() == typeof(LegacySkin); } diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs index 341a881789..2faaa9a905 100644 --- a/osu.Game/Skinning/SkinnableTargetContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -85,7 +85,7 @@ namespace osu.Game.Skinning if (!(component is Drawable drawable)) throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); - content.Remove(drawable); + content.Remove(drawable, true); components.Remove(component); } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs similarity index 93% rename from osu.Game/Skinning/DefaultSkin.cs rename to osu.Game/Skinning/TrianglesSkin.cs index 5267861e3e..2c70963524 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -9,14 +9,12 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; using osu.Game.IO; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osuTK; @@ -24,26 +22,26 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class DefaultSkin : Skin + public class TrianglesSkin : Skin { public static SkinInfo CreateInfo() => new SkinInfo { - ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN, - Name = "osu! (triangles)", + ID = osu.Game.Skinning.SkinInfo.TRIANGLES_SKIN, + Name = "osu! \"triangles\" (2017)", Creator = "team osu!", Protected = true, - InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() + InstantiationInfo = typeof(TrianglesSkin).GetInvariantInstantiationInfo() }; private readonly IStorageResourceProvider resources; - public DefaultSkin(IStorageResourceProvider resources) + public TrianglesSkin(IStorageResourceProvider resources) : this(CreateInfo(), resources) { } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] - public DefaultSkin(SkinInfo skin, IStorageResourceProvider resources) + public TrianglesSkin(SkinInfo skin, IStorageResourceProvider resources) : base(skin, resources) { this.resources = resources; @@ -55,7 +53,7 @@ namespace osu.Game.Skinning { foreach (string lookup in sampleInfo.LookupNames) { - var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup); + var sample = Samples?.Get(lookup) ?? resources.AudioManager?.Samples.Get(lookup); if (sample != null) return sample; } @@ -147,7 +145,7 @@ namespace osu.Game.Skinning new DefaultScoreCounter(), new DefaultAccuracyCounter(), new DefaultHealthDisplay(), - new SongProgress(), + new DefaultSongProgress(), new BarHitErrorMeter(), new BarHitErrorMeter(), new PerformancePointsCounter() diff --git a/osu.Game/Storyboards/CommandTimeline.cs b/osu.Game/Storyboards/CommandTimeline.cs index 4d0da9597b..d1a1edcd03 100644 --- a/osu.Game/Storyboards/CommandTimeline.cs +++ b/osu.Game/Storyboards/CommandTimeline.cs @@ -27,7 +27,10 @@ namespace osu.Game.Storyboards public void Add(Easing easing, double startTime, double endTime, T startValue, T endValue) { if (endTime < startTime) - return; + { + (startTime, endTime) = (endTime, startTime); + (startValue, endValue) = (endValue, startValue); + } commands.Add(new TypedCommand { Easing = easing, StartTime = startTime, EndTime = endTime, StartValue = startValue, EndValue = endValue }); diff --git a/osu.Game/Storyboards/CommandTimelineGroup.cs b/osu.Game/Storyboards/CommandTimelineGroup.cs index 6fc9f60177..de5da3118a 100644 --- a/osu.Game/Storyboards/CommandTimelineGroup.cs +++ b/osu.Game/Storyboards/CommandTimelineGroup.cs @@ -47,30 +47,11 @@ namespace osu.Game.Storyboards }; } - /// - /// Returns the earliest visible time. Will be null unless this group's first command has a start value of zero. - /// - public double? EarliestDisplayedTime - { - get - { - var first = Alpha.Commands.FirstOrDefault(); - - return first?.StartValue == 0 ? first.StartTime : null; - } - } - [JsonIgnore] public double CommandsStartTime { get { - // if the first alpha command starts at zero it should be given priority over anything else. - // this is due to it creating a state where the target is not present before that time, causing any other events to not be visible. - double? earliestDisplay = EarliestDisplayedTime; - if (earliestDisplay != null) - return earliestDisplay.Value; - double min = double.MaxValue; for (int i = 0; i < timelines.Length; i++) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index be98ca9a46..6295604438 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -85,12 +85,12 @@ namespace osu.Game.Storyboards.Drawables } [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm) + private void load(IGameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm) { if (clock != null) Clock = clock; - dependencies.Cache(new TextureStore(host.CreateTextureLoaderStore(new RealmFileStore(realm, host.Storage).Store), false, scaleAdjust: 1)); + dependencies.Cache(new TextureStore(host.Renderer, host.CreateTextureLoaderStore(new RealmFileStore(realm, host.Storage).Store), false, scaleAdjust: 1)); foreach (var layer in Storyboard.Layers) { diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 9822f36620..07e1e86617 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Skinning; using osuTK; @@ -56,11 +57,6 @@ namespace osu.Game.Storyboards.Drawables get => vectorScale; set { - if (Math.Abs(value.X) < Precision.FLOAT_EPSILON) - value.X = Precision.FLOAT_EPSILON; - if (Math.Abs(value.Y) < Precision.FLOAT_EPSILON) - value.Y = Precision.FLOAT_EPSILON; - if (vectorScale == value) return; @@ -95,6 +91,9 @@ namespace osu.Game.Storyboards.Drawables [Resolved] private ISkinSource skin { get; set; } + [Resolved] + private IBeatSyncProvider beatSyncProvider { get; set; } + [BackgroundDependencyLoader] private void load(TextureStore textureStore, Storyboard storyboard) { @@ -120,6 +119,18 @@ namespace osu.Game.Storyboards.Drawables Animation.ApplyTransforms(this); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // Framework animation class tries its best to synchronise the animation at LoadComplete, + // but in some cases (such as fast forward) this results in an incorrect start offset. + // + // In the case of storyboard animations, we want to synchronise with game time perfectly + // so let's get a correct time based on gameplay clock and earliest transform. + PlaybackPosition = (beatSyncProvider.Clock?.CurrentTime ?? Clock.CurrentTime) - Animation.EarliestTransformTime; + } + private void skinSourceChanged() { ClearFrames(); diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index 28ed2e65e3..b86b021d51 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -55,11 +55,6 @@ namespace osu.Game.Storyboards.Drawables get => vectorScale; set { - if (Math.Abs(value.X) < Precision.FLOAT_EPSILON) - value.X = Precision.FLOAT_EPSILON; - if (Math.Abs(value.Y) < Precision.FLOAT_EPSILON) - value.Y = Precision.FLOAT_EPSILON; - if (vectorScale == value) return; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs index 23b1004e74..ebd056ba50 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -1,10 +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; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,14 +8,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Video; using osu.Game.Beatmaps; -using osu.Game.Extensions; namespace osu.Game.Storyboards.Drawables { public class DrawableStoryboardVideo : CompositeDrawable { public readonly StoryboardVideo Video; - private Video video; + + private Video? drawableVideo; public override bool RemoveWhenNotAlive => false; @@ -33,7 +29,7 @@ namespace osu.Game.Storyboards.Drawables [BackgroundDependencyLoader(true)] private void load(IBindable beatmap, TextureStore textureStore) { - string path = beatmap.Value.BeatmapSetInfo?.Files.FirstOrDefault(f => f.Filename.Equals(Video.Path, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); + string? path = beatmap.Value.BeatmapSetInfo?.GetPathForFile(Video.Path); if (path == null) return; @@ -43,7 +39,7 @@ namespace osu.Game.Storyboards.Drawables if (stream == null) return; - InternalChild = video = new Video(stream, false) + InternalChild = drawableVideo = new Video(stream, false) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fill, @@ -57,12 +53,12 @@ namespace osu.Game.Storyboards.Drawables { base.LoadComplete(); - if (video == null) return; + if (drawableVideo == null) return; - using (video.BeginAbsoluteSequence(Video.StartTime)) + using (drawableVideo.BeginAbsoluteSequence(Video.StartTime)) { - Schedule(() => video.PlaybackPosition = Time.Current - Video.StartTime); - video.FadeIn(500); + Schedule(() => drawableVideo.PlaybackPosition = Time.Current - Video.StartTime); + drawableVideo.FadeIn(500); } } } diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index f30c3f7f94..473f1ce97f 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -1,14 +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 System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; -using osu.Game.Extensions; using osu.Game.Rulesets.Mods; using osu.Game.Storyboards.Drawables; @@ -90,12 +86,12 @@ namespace osu.Game.Storyboards } } - public DrawableStoryboard CreateDrawable(IReadOnlyList mods = null) => + public DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) => new DrawableStoryboard(this, mods); - public Texture GetTextureFromPath(string path, TextureStore textureStore) + public Texture? GetTextureFromPath(string path, TextureStore textureStore) { - string storyboardPath = BeatmapInfo.BeatmapSet?.Files.FirstOrDefault(f => f.Filename.Equals(path, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); + string? storyboardPath = BeatmapInfo.BeatmapSet?.GetPathForFile(path); if (!string.IsNullOrEmpty(storyboardPath)) return textureStore.Get(storyboardPath); diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index a759482b5d..cd7788bb08 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -30,24 +30,43 @@ namespace osu.Game.Storyboards { get { - // check for presence affecting commands as an initial pass. - double earliestStartTime = TimelineGroup.EarliestDisplayedTime ?? double.MaxValue; + // To get the initial start time, we need to check whether the first alpha command to exist (across all loops) has a StartValue of zero. + // A StartValue of zero governs, above all else, the first valid display time of a sprite. + // + // You can imagine that the first command of each type decides that type's start value, so if the initial alpha is zero, + // anything before that point can be ignored (the sprite is not visible after all). + var alphaCommands = new List<(double startTime, bool isZeroStartValue)>(); - foreach (var l in loops) + var command = TimelineGroup.Alpha.Commands.FirstOrDefault(); + if (command != null) alphaCommands.Add((command.StartTime, command.StartValue == 0)); + + foreach (var loop in loops) { - if (l.EarliestDisplayedTime is double loopEarliestDisplayTime) - earliestStartTime = Math.Min(earliestStartTime, l.LoopStartTime + loopEarliestDisplayTime); + command = loop.Alpha.Commands.FirstOrDefault(); + if (command != null) alphaCommands.Add((command.StartTime + loop.LoopStartTime, command.StartValue == 0)); } - if (earliestStartTime < double.MaxValue) - return earliestStartTime; + if (alphaCommands.Count > 0) + { + var firstAlpha = alphaCommands.OrderBy(t => t.startTime).First(); - // if an alpha-affecting command was not found, use the earliest of any command. - earliestStartTime = TimelineGroup.StartTime; + if (firstAlpha.isZeroStartValue) + return firstAlpha.startTime; + } + return EarliestTransformTime; + } + } + + public double EarliestTransformTime + { + get + { + // If we got to this point, either no alpha commands were present, or the earliest had a non-zero start value. + // The sprite's StartTime will be determined by the earliest command, regardless of type. + double earliestStartTime = TimelineGroup.StartTime; foreach (var l in loops) earliestStartTime = Math.Min(earliestStartTime, l.StartTime); - return earliestStartTime; } } diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 3ed3bb65c9..c97eec116c 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -12,8 +12,10 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Beatmaps; @@ -39,6 +41,9 @@ namespace osu.Game.Tests.Beatmaps [Resolved] private RulesetStore rulesetStore { get; set; } + [Resolved] + private GameHost host { get; set; } + private readonly SkinInfo userSkinInfo = new SkinInfo(); private readonly BeatmapInfo beatmapInfo = new BeatmapInfo @@ -123,6 +128,7 @@ namespace osu.Game.Tests.Beatmaps #region IResourceStorageProvider + public IRenderer Renderer => host.Renderer; public AudioManager AudioManager => Audio; public IResourceStore Files => userSkinResourceStore; public new IResourceStore Resources => base.Resources; @@ -212,6 +218,7 @@ namespace osu.Game.Tests.Beatmaps protected internal override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, this); + public IRenderer Renderer => resources.Renderer; public AudioManager AudioManager => resources.AudioManager; public IResourceStore Files { get; } diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index 2306fd1c3e..3d7ebad831 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -31,8 +31,6 @@ namespace osu.Game.Tests.Beatmaps this.storyboard = storyboard; } - public override bool TrackLoaded => true; - public override bool BeatmapLoaded => true; protected override IBeatmap GetBeatmap() => beatmap; diff --git a/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs b/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs index a80154c38e..cf637983d9 100644 --- a/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs +++ b/osu.Game/Tests/Rulesets/TestRulesetConfigCache.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.Concurrent; using osu.Game.Rulesets; using osu.Game.Rulesets.Configuration; @@ -14,8 +12,8 @@ namespace osu.Game.Tests.Rulesets /// public class TestRulesetConfigCache : IRulesetConfigCache { - private readonly ConcurrentDictionary configCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary configCache = new ConcurrentDictionary(); - public IRulesetConfigManager GetConfigFor(Ruleset ruleset) => configCache.GetOrAdd(ruleset.ShortName, _ => ruleset.CreateConfig(null)); + public IRulesetConfigManager? GetConfigFor(Ruleset ruleset) => configCache.GetOrAdd(ruleset.ShortName, _ => ruleset.CreateConfig(null)); } } diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 91284ae499..8f1e7abd9e 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -6,6 +6,8 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Overlays; @@ -24,30 +26,39 @@ namespace osu.Game.Tests.Visual protected readonly BindableBeatDivisor BeatDivisor = new BindableBeatDivisor(); - [Cached] - protected new readonly EditorClock Clock; + protected EditorClock EditorClock; private readonly Bindable frequencyAdjustment = new BindableDouble(1); + private IBeatmap editorClockBeatmap; protected virtual bool ScrollUsingMouseWheel => true; - protected EditorClockTestScene() - { - Clock = new EditorClock(new Beatmap(), BeatDivisor) { IsCoupled = false }; - } + protected override Container Content => content; + + private readonly Container content = new Container { RelativeSizeAxes = Axes.Both }; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + editorClockBeatmap = CreateEditorClockBeatmap(); + + base.Content.AddRange(new Drawable[] + { + EditorClock = new EditorClock(editorClockBeatmap, BeatDivisor), + content + }); + dependencies.Cache(BeatDivisor); - dependencies.CacheAs(Clock); + dependencies.CacheAs(EditorClock); return dependencies; } protected override void LoadComplete() { + Beatmap.Value = CreateWorkingBeatmap(editorClockBeatmap); + base.LoadComplete(); Beatmap.BindValueChanged(beatmapChanged, true); @@ -55,22 +66,13 @@ namespace osu.Game.Tests.Visual AddSliderStep("editor clock rate", 0.0, 2.0, 1.0, v => frequencyAdjustment.Value = v); } + protected virtual IBeatmap CreateEditorClockBeatmap() => new Beatmap(); + private void beatmapChanged(ValueChangedEvent e) { e.OldValue?.Track.RemoveAdjustment(AdjustableProperty.Frequency, frequencyAdjustment); - - Clock.Beatmap = e.NewValue.Beatmap; - Clock.ChangeSource(e.NewValue.Track); - Clock.ProcessFrame(); - e.NewValue.Track.AddAdjustment(AdjustableProperty.Frequency, frequencyAdjustment); - } - - protected override void Update() - { - base.Update(); - - Clock.ProcessFrame(); + EditorClock.ChangeSource(e.NewValue.Track); } protected override bool OnScroll(ScrollEvent e) @@ -79,9 +81,9 @@ namespace osu.Game.Tests.Visual return false; if (e.ScrollDelta.Y > 0) - Clock.SeekBackward(true); + EditorClock.SeekBackward(true); else - Clock.SeekForward(true); + EditorClock.SeekForward(true); return true; } diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index 31036247ab..ced72aa593 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -139,7 +139,7 @@ namespace osu.Game.Tests.Visual public WorkingBeatmap TestBeatmap; public TestBeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host, WorkingBeatmap defaultBeatmap) - : base(storage, realm, rulesets, api, audioManager, resources, host, defaultBeatmap) + : base(storage, realm, api, audioManager, resources, host, defaultBeatmap) { } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 5ea98bdbb1..101a347749 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -3,7 +3,6 @@ #nullable disable -using NUnit.Framework; using osu.Game.Online.Rooms; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual.OnlinePlay; @@ -34,13 +33,6 @@ namespace osu.Game.Tests.Visual.Multiplayer this.joinRoom = joinRoom; } - [SetUp] - public new void Setup() => Schedule(() => - { - if (joinRoom) - SelectedRoom.Value = CreateRoom(); - }); - protected virtual Room CreateRoom() { return new Room @@ -63,7 +55,12 @@ namespace osu.Game.Tests.Visual.Multiplayer if (joinRoom) { - AddStep("join room", () => RoomManager.CreateRoom(SelectedRoom.Value)); + AddStep("join room", () => + { + SelectedRoom.Value = CreateRoom(); + RoomManager.CreateRoom(SelectedRoom.Value); + }); + AddUntilStep("wait for room join", () => RoomJoined); } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 19b887eea5..84737bce3f 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -81,13 +81,14 @@ namespace osu.Game.Tests.Visual.Multiplayer public void Disconnect() => isConnected.Value = false; public MultiplayerRoomUser AddUser(APIUser user, bool markAsPlaying = false) - { - var roomUser = new MultiplayerRoomUser(user.Id) { User = user }; + => AddUser(new MultiplayerRoomUser(user.Id) { User = user }, markAsPlaying); + public MultiplayerRoomUser AddUser(MultiplayerRoomUser roomUser, bool markAsPlaying = false) + { addUser(roomUser); if (markAsPlaying) - PlayingUserIds.Add(user.Id); + PlayingUserIds.Add(roomUser.UserID); return roomUser; } diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index 6577057c17..b9c293c3aa 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -4,7 +4,6 @@ #nullable disable using System; -using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -56,39 +55,43 @@ namespace osu.Game.Tests.Visual.OnlinePlay return dependencies; } - [SetUp] - public void Setup() => Schedule(() => + public override void SetUpSteps() { - // Reset the room dependencies to a fresh state. - drawableDependenciesContainer.Clear(); - dependencies.OnlinePlayDependencies = CreateOnlinePlayDependencies(); - drawableDependenciesContainer.AddRange(OnlinePlayDependencies.DrawableComponents); + base.SetUpSteps(); - var handler = OnlinePlayDependencies.RequestsHandler; - - // Resolving the BeatmapManager in the test scene will inject the game-wide BeatmapManager, while many test scenes cache their own BeatmapManager instead. - // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. - var beatmapManager = dependencies.Get(); - - ((DummyAPIAccess)API).HandleRequest = request => + AddStep("setup dependencies", () => { - try + // Reset the room dependencies to a fresh state. + drawableDependenciesContainer.Clear(); + dependencies.OnlinePlayDependencies = CreateOnlinePlayDependencies(); + drawableDependenciesContainer.AddRange(OnlinePlayDependencies.DrawableComponents); + + var handler = OnlinePlayDependencies.RequestsHandler; + + // Resolving the BeatmapManager in the test scene will inject the game-wide BeatmapManager, while many test scenes cache their own BeatmapManager instead. + // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. + var beatmapManager = dependencies.Get(); + + ((DummyAPIAccess)API).HandleRequest = request => { - return handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); - } - catch (ObjectDisposedException) - { - // These requests can be fired asynchronously, but potentially arrive after game components - // have been disposed (ie. realm in BeatmapManager). - // This only happens in tests and it's easiest to ignore them for now. - Logger.Log($"Handled {nameof(ObjectDisposedException)} in test request handling"); - return true; - } - }; - }); + try + { + return handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); + } + catch (ObjectDisposedException) + { + // These requests can be fired asynchronously, but potentially arrive after game components + // have been disposed (ie. realm in BeatmapManager). + // This only happens in tests and it's easiest to ignore them for now. + Logger.Log($"Handled {nameof(ObjectDisposedException)} in test request handling"); + return true; + } + }; + }); + } /// - /// Creates the room dependencies. Called every . + /// Creates the room dependencies. Called every . /// /// /// Any custom dependencies required for online play sub-classes should be added here. diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index e7c83ca1f9..ef4539ba56 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -135,25 +135,55 @@ namespace osu.Game.Tests.Visual.OnlinePlay }); return true; + case GetBeatmapRequest getBeatmapRequest: + { + getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.BeatmapInfo.OnlineID).Single()); + return true; + } + case GetBeatmapsRequest getBeatmapsRequest: - var result = new List(); + { + getBeatmapsRequest.TriggerSuccess(new GetBeatmapsResponse { Beatmaps = createResponseBeatmaps(getBeatmapsRequest.BeatmapIds.ToArray()) }); + return true; + } - foreach (int id in getBeatmapsRequest.BeatmapIds) + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var baseBeatmap = getBeatmapSetRequest.Type == BeatmapSetLookupType.BeatmapId + ? beatmapManager.QueryBeatmap(b => b.OnlineID == getBeatmapSetRequest.ID) + : beatmapManager.QueryBeatmap(b => b.BeatmapSet.OnlineID == getBeatmapSetRequest.ID); + + if (baseBeatmap == null) { - var baseBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id); - - if (baseBeatmap == null) - { - baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; - baseBeatmap.OnlineID = id; - baseBeatmap.BeatmapSet!.OnlineID = id; - } - - result.Add(OsuTestScene.CreateAPIBeatmap(baseBeatmap)); + baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; + baseBeatmap.OnlineID = getBeatmapSetRequest.ID; + baseBeatmap.BeatmapSet!.OnlineID = getBeatmapSetRequest.ID; } - getBeatmapsRequest.TriggerSuccess(new GetBeatmapsResponse { Beatmaps = result }); + getBeatmapSetRequest.TriggerSuccess(OsuTestScene.CreateAPIBeatmapSet(baseBeatmap)); return true; + } + } + + List createResponseBeatmaps(params int[] beatmapIds) + { + var result = new List(); + + foreach (int id in beatmapIds) + { + var baseBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id); + + if (baseBeatmap == null) + { + baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; + baseBeatmap.OnlineID = id; + baseBeatmap.BeatmapSet!.OnlineID = id; + } + + result.Add(OsuTestScene.CreateAPIBeatmap(baseBeatmap)); + } + + return result; } return false; diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index b9f6183869..3ca83a4781 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -17,6 +17,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays; @@ -42,6 +43,8 @@ namespace osu.Game.Tests.Visual protected override bool CreateNestedActionContainer => false; + protected override bool DisplayCursorForManualInput => false; + [BackgroundDependencyLoader] private void load() { @@ -58,10 +61,7 @@ namespace osu.Game.Tests.Visual AddStep("Create new game instance", () => { if (Game?.Parent != null) - { - Remove(Game); - Game.Dispose(); - } + Remove(Game, true); RecycleLocalStorage(false); @@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual public RealmAccess Realm => Dependencies.Get(); + public new GlobalCursorDisplay GlobalCursorDisplay => base.GlobalCursorDisplay; + public new BackButton BackButton => base.BackButton; public new BeatmapManager BeatmapManager => base.BeatmapManager; @@ -171,6 +173,11 @@ namespace osu.Game.Tests.Visual API.Login("Rhythm Champion", "osu!"); Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true); + + // set applied version to latest so that the BackgroundBeatmapProcessor doesn't consider + // beatmap star ratings as outdated and reset them throughout the test. + foreach (var ruleset in RulesetStore.AvailableRulesets) + ruleset.LastAppliedDifficultyVersion = ruleset.CreateInstance().CreateDifficultyCalculator(Beatmap.Default).Version; } protected override void Update() diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index eccd2efa96..e56c546bac 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -36,21 +36,31 @@ namespace osu.Game.Tests.Visual /// protected virtual bool CreateNestedActionContainer => true; + /// + /// Whether a menu cursor controlled by the manual input manager should be displayed. + /// True by default, but is disabled for s as they provide their own global cursor. + /// + protected virtual bool DisplayCursorForManualInput => true; + protected OsuManualInputManagerTestScene() { - MenuCursorContainer cursorContainer; + var mainContent = content = new Container { RelativeSizeAxes = Axes.Both }; - CompositeDrawable mainContent = cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }; - - cursorContainer.Child = content = new OsuTooltipContainer(cursorContainer.Cursor) + if (DisplayCursorForManualInput) { - RelativeSizeAxes = Axes.Both - }; + var cursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }; + + cursorDisplay.Add(new OsuTooltipContainer(cursorDisplay.MenuCursor) + { + RelativeSizeAxes = Axes.Both, + Child = mainContent + }); + + mainContent = cursorDisplay; + } if (CreateNestedActionContainer) - { mainContent = new GlobalActionContainer(null).WithChild(mainContent); - } base.Content.AddRange(new Drawable[] { diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 012c512266..5055153691 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -365,6 +365,11 @@ namespace osu.Game.Tests.Visual } else track = audio?.Tracks.GetVirtual(trackLength); + + // We are guaranteed to have a virtual track. + // To ease testability, ensure the track is available from point of construction. + // (Usually this would be done by MusicController for us). + LoadTrack(); } ~ClockBackedTestWorkingBeatmap() @@ -399,9 +404,9 @@ namespace osu.Game.Tests.Visual public IEnumerable GetAvailableResources() => throw new NotImplementedException(); - public Track GetVirtual(double length = double.PositiveInfinity) + public Track GetVirtual(double length = double.PositiveInfinity, string name = "virtual") { - var track = new TrackVirtualManual(referenceClock) { Length = length }; + var track = new TrackVirtualManual(referenceClock, name) { Length = length }; AddItem(track); return track; } @@ -416,7 +421,8 @@ namespace osu.Game.Tests.Visual private bool running; - public TrackVirtualManual(IFrameBasedClock referenceClock) + public TrackVirtualManual(IFrameBasedClock referenceClock, string name = "virtual") + : base(name) { this.referenceClock = referenceClock; Length = double.PositiveInfinity; diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 797e8363c3..7e5681ee81 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -30,10 +30,15 @@ namespace osu.Game.Tests.Visual { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(new EditorClock()); - var playable = GetPlayableBeatmap(); - dependencies.CacheAs(new EditorBeatmap(playable)); + + var editorClock = new EditorClock(); + base.Content.Add(editorClock); + dependencies.CacheAs(editorClock); + + var editorBeatmap = new EditorBeatmap(playable); + // Not adding to hierarchy as we don't satisfy its dependencies. Probably not good. + dependencies.CacheAs(editorBeatmap); return dependencies; } @@ -67,7 +72,7 @@ namespace osu.Game.Tests.Visual protected void ResetPlacement() { if (CurrentBlueprint != null) - Remove(CurrentBlueprint); + Remove(CurrentBlueprint, true); Add(CurrentBlueprint = CreateBlueprint()); } diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index a9decbae57..9bad867206 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -7,7 +7,6 @@ using System; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets; @@ -57,7 +56,9 @@ namespace osu.Game.Tests.Visual protected virtual bool Autoplay => false; - protected void LoadPlayer() + protected void LoadPlayer() => LoadPlayer(Array.Empty()); + + protected void LoadPlayer(Mod[] mods) { var ruleset = CreatePlayerRuleset(); Ruleset.Value = ruleset.RulesetInfo; @@ -65,20 +66,21 @@ namespace osu.Game.Tests.Visual var beatmap = CreateBeatmap(ruleset.RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(beatmap); - SelectedMods.Value = Array.Empty(); + + SelectedMods.Value = mods; if (!AllowFail) { var noFailMod = ruleset.CreateMod(); if (noFailMod != null) - SelectedMods.Value = new[] { noFailMod }; + SelectedMods.Value = SelectedMods.Value.Append(noFailMod).ToArray(); } if (Autoplay) { var mod = ruleset.GetAutoplayMod(); if (mod != null) - SelectedMods.Value = SelectedMods.Value.Concat(mod.Yield()).ToArray(); + SelectedMods.Value = SelectedMods.Value.Append(mod).ToArray(); } Player = CreatePlayer(ruleset); diff --git a/osu.Game/Tests/Visual/ScrollingTestContainer.cs b/osu.Game/Tests/Visual/ScrollingTestContainer.cs index cf7fe6e45d..1817a704b9 100644 --- a/osu.Game/Tests/Visual/ScrollingTestContainer.cs +++ b/osu.Game/Tests/Visual/ScrollingTestContainer.cs @@ -99,8 +99,8 @@ namespace osu.Game.Tests.Visual public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) => implementation.GetLength(startTime, endTime, timeRange, scrollLength); - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => implementation.PositionAt(time, currentTime, timeRange, scrollLength); + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) + => implementation.PositionAt(time, currentTime, timeRange, scrollLength, originTime); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) => implementation.TimeAt(position, currentTime, timeRange, scrollLength); diff --git a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs index 66d79cad1c..ac0d1cd366 100644 --- a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs @@ -1,9 +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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,19 +16,23 @@ namespace osu.Game.Tests.Visual [Cached] private readonly EditorClock editorClock = new EditorClock(); - protected override Container Content => content ?? base.Content; + protected override Container Content => content; private readonly Container content; protected SelectionBlueprintTestScene() { - base.Content.Add(content = new Container + base.Content.AddRange(new Drawable[] { - Clock = new FramedClock(new StopwatchClock()), - RelativeSizeAxes = Axes.Both + editorClock, + content = new Container + { + Clock = new FramedClock(new StopwatchClock()), + RelativeSizeAxes = Axes.Both + } }); } - protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, [CanBeNull] DrawableHitObject drawableObject = null) + protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, DrawableHitObject? drawableObject = null) { Add(blueprint.With(d => { diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index 2e5d0534f6..f8f15e2729 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -11,7 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; @@ -29,8 +29,10 @@ namespace osu.Game.Tests.Visual { public abstract class SkinnableTestScene : OsuGridTestScene, IStorageResourceProvider { + private TrianglesSkin trianglesSkin; private Skin metricsSkin; - private Skin defaultSkin; + private Skin legacySkin; + private Skin argonSkin; private Skin specialSkin; private Skin oldSkin; @@ -47,8 +49,10 @@ namespace osu.Game.Tests.Visual { var dllStore = new DllResourceStore(GetType().Assembly); + argonSkin = new ArgonSkin(this); + trianglesSkin = new TrianglesSkin(this); metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), this, true); - defaultSkin = new DefaultLegacySkin(this); + legacySkin = new DefaultLegacySkin(this); specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore(dllStore, "Resources/special_skin"), this, true); oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore(dllStore, "Resources/old_skin"), this, true); } @@ -61,11 +65,12 @@ namespace osu.Game.Tests.Visual var beatmap = CreateBeatmapForSkinProvider(); - Cell(0).Child = createProvider(null, creationFunction, beatmap); - Cell(1).Child = createProvider(metricsSkin, creationFunction, beatmap); - Cell(2).Child = createProvider(defaultSkin, creationFunction, beatmap); - Cell(3).Child = createProvider(specialSkin, creationFunction, beatmap); - Cell(4).Child = createProvider(oldSkin, creationFunction, beatmap); + Cell(0).Child = createProvider(argonSkin, creationFunction, beatmap); + Cell(1).Child = createProvider(trianglesSkin, creationFunction, beatmap); + Cell(2).Child = createProvider(metricsSkin, creationFunction, beatmap); + Cell(3).Child = createProvider(legacySkin, creationFunction, beatmap); + Cell(4).Child = createProvider(specialSkin, creationFunction, beatmap); + Cell(5).Child = createProvider(oldSkin, creationFunction, beatmap); } protected IEnumerable CreatedDrawables => createdDrawables; @@ -80,10 +85,7 @@ namespace osu.Game.Tests.Visual OutlineBox outlineBox; SkinProvidingContainer skinProvider; - ISkin provider = skin; - - if (provider is LegacySkin legacyProvider) - provider = Ruleset.Value.CreateInstance().CreateLegacySkinProvider(legacyProvider, beatmap); + ISkin provider = Ruleset.Value.CreateInstance().CreateSkinTransformer(skin, beatmap) ?? skin; var children = new Container { @@ -102,7 +104,7 @@ namespace osu.Game.Tests.Visual }, new OsuSpriteText { - Text = skin?.SkinInfo.Value.Name ?? "none", + Text = skin.SkinInfo.Value.Name, Scale = new Vector2(1.5f), Padding = new MarginPadding(5), }, @@ -138,6 +140,7 @@ namespace osu.Game.Tests.Visual { c.RelativeSizeAxes = Axes.None; c.AutoSizeAxes = Axes.None; + c.Size = Vector2.Zero; c.RelativeSizeAxes = !autoSize ? Axes.Both : Axes.None; c.AutoSizeAxes = autoSize ? Axes.Both : Axes.None; @@ -159,6 +162,7 @@ namespace osu.Game.Tests.Visual #region IResourceStorageProvider + public IRenderer Renderer => host.Renderer; public AudioManager AudioManager => Audio; public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index 2531f3c485..e6d8e473bb 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -37,6 +38,7 @@ namespace osu.Game.Tests.Visual.Spectator private readonly Dictionary lastReceivedUserFrames = new Dictionary(); private readonly Dictionary userBeatmapDictionary = new Dictionary(); + private readonly Dictionary userModsDictionary = new Dictionary(); private readonly Dictionary userNextFrameDictionary = new Dictionary(); [Resolved] @@ -52,9 +54,11 @@ namespace osu.Game.Tests.Visual.Spectator /// /// The user to start play for. /// The playing beatmap id. - public void SendStartPlay(int userId, int beatmapId) + /// The mods the user has applied. + public void SendStartPlay(int userId, int beatmapId, APIMod[]? mods = null) { userBeatmapDictionary[userId] = beatmapId; + userModsDictionary[userId] = mods ?? Array.Empty(); userNextFrameDictionary[userId] = 0; sendPlayingState(userId); } @@ -73,10 +77,12 @@ namespace osu.Game.Tests.Visual.Spectator { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, + Mods = userModsDictionary[userId], State = state }); userBeatmapDictionary.Remove(userId); + userModsDictionary.Remove(userId); } /// @@ -125,6 +131,7 @@ namespace osu.Game.Tests.Visual.Spectator // Track the local user's playing beatmap ID. Debug.Assert(state.BeatmapID != null); userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value; + userModsDictionary[api.LocalUser.Value.Id] = state.Mods.ToArray(); return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state); } @@ -158,6 +165,7 @@ namespace osu.Game.Tests.Visual.Spectator { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, + Mods = userModsDictionary[userId], State = SpectatedUserState.Playing }); } diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index e2ddd1734d..93a155e083 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.API; using osu.Game.Online.Rooms; +using osu.Game.Online.Spectator; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -46,6 +47,9 @@ namespace osu.Game.Tests.Visual public readonly List Results = new List(); + [Resolved] + private SpectatorClient spectatorClient { get; set; } + public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) : base(new PlayerConfiguration { @@ -98,5 +102,15 @@ namespace osu.Game.Tests.Visual ScoreProcessor.NewJudgement += r => Results.Add(r); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + // Specific to tests, the player can be disposed without OnExiting() ever being called. + // We should make sure that the gameplay session has finished even in this case. + if (LoadedBeatmapSuccessfully) + spectatorClient?.EndPlaying(GameplayState); + } } } diff --git a/osu.Game/Tests/VisualTestRunner.cs b/osu.Game/Tests/VisualTestRunner.cs index bd98482768..c8279b9e3c 100644 --- a/osu.Game/Tests/VisualTestRunner.cs +++ b/osu.Game/Tests/VisualTestRunner.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tests [STAThread] public static int Main(string[] args) { - using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu", new HostOptions { BindIPC = true, })) + using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu-development", new HostOptions { BindIPC = true, })) { host.Run(new OsuTestBrowser()); return 0; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index d60f2e4a4b..7b540cb564 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.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.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osuTK; namespace osu.Game.Updater { @@ -27,13 +27,13 @@ namespace osu.Game.Updater GetType() != typeof(UpdateManager); [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; [Resolved] - protected INotificationOverlay Notifications { get; private set; } + protected INotificationOverlay Notifications { get; private set; } = null!; protected override void LoadComplete() { @@ -59,7 +59,7 @@ namespace osu.Game.Updater private readonly object updateTaskLock = new object(); - private Task updateCheckTask; + private Task? updateCheckTask; public async Task CheckForUpdateAsync() { @@ -99,7 +99,7 @@ namespace osu.Game.Updater private void load(OsuColour colours, ChangelogOverlay changelog, INotificationOverlay notificationOverlay) { Icon = FontAwesome.Solid.CheckSquare; - IconBackground.Colour = colours.BlueDark; + IconContent.Colour = colours.BlueDark; Activated = delegate { @@ -109,5 +109,77 @@ namespace osu.Game.Updater }; } } + + public class UpdateApplicationCompleteNotification : ProgressCompletionNotification + { + public UpdateApplicationCompleteNotification() + { + Text = @"Update ready to install. Click to restart!"; + } + } + + public class UpdateProgressNotification : ProgressNotification + { + protected override Notification CreateCompletionNotification() => new UpdateApplicationCompleteNotification + { + Activated = CompletionClickAction + }; + + [BackgroundDependencyLoader] + private void load() + { + IconContent.AddRange(new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Upload, + Size = new Vector2(34), + Colour = OsuColour.Gray(0.2f), + Depth = float.MaxValue, + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + StartDownload(); + } + + public override void Close(bool runFlingAnimation) + { + // cancelling updates is not currently supported by the underlying updater. + // only allow dismissing for now. + + switch (State) + { + case ProgressNotificationState.Cancelled: + case ProgressNotificationState.Completed: + base.Close(runFlingAnimation); + break; + } + } + + public void StartDownload() + { + State = ProgressNotificationState.Active; + Progress = 0; + Text = @"Downloading update..."; + } + + public void StartInstall() + { + Progress = 0; + Text = @"Installing update..."; + } + + public void FailDownload() + { + State = ProgressNotificationState.Cancelled; + Close(false); + } + } } } diff --git a/osu.Game/Users/Country.cs b/osu.Game/Users/Country.cs deleted file mode 100644 index b38600fa1b..0000000000 --- a/osu.Game/Users/Country.cs +++ /dev/null @@ -1,27 +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 Newtonsoft.Json; - -namespace osu.Game.Users -{ - public class Country : IEquatable - { - /// - /// The name of this country. - /// - [JsonProperty(@"name")] - public string FullName; - - /// - /// Two-letter flag acronym (ISO 3166 standard) - /// - [JsonProperty(@"code")] - public string FlagName; - - public bool Equals(Country other) => FlagName == other?.FlagName; - } -} diff --git a/osu.Game/Users/CountryCode.cs b/osu.Game/Users/CountryCode.cs new file mode 100644 index 0000000000..775de2bcf5 --- /dev/null +++ b/osu.Game/Users/CountryCode.cs @@ -0,0 +1,768 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace osu.Game.Users +{ + [JsonConverter(typeof(StringEnumConverter))] + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + public enum CountryCode + { + [Description("Unknown")] + Unknown = 0, + + [Description("Bangladesh")] + BD, + + [Description("Belgium")] + BE, + + [Description("Burkina Faso")] + BF, + + [Description("Bulgaria")] + BG, + + [Description("Bosnia and Herzegovina")] + BA, + + [Description("Barbados")] + BB, + + [Description("Wallis and Futuna")] + WF, + + [Description("Saint Barthelemy")] + BL, + + [Description("Bermuda")] + BM, + + [Description("Brunei")] + BN, + + [Description("Bolivia")] + BO, + + [Description("Bahrain")] + BH, + + [Description("Burundi")] + BI, + + [Description("Benin")] + BJ, + + [Description("Bhutan")] + BT, + + [Description("Jamaica")] + JM, + + [Description("Bouvet Island")] + BV, + + [Description("Botswana")] + BW, + + [Description("Samoa")] + WS, + + [Description("Bonaire, Saint Eustatius and Saba")] + BQ, + + [Description("Brazil")] + BR, + + [Description("Bahamas")] + BS, + + [Description("Jersey")] + JE, + + [Description("Belarus")] + BY, + + [Description("Belize")] + BZ, + + [Description("Russia")] + RU, + + [Description("Rwanda")] + RW, + + [Description("Serbia")] + RS, + + [Description("East Timor")] + TL, + + [Description("Reunion")] + RE, + + [Description("Turkmenistan")] + TM, + + [Description("Tajikistan")] + TJ, + + [Description("Romania")] + RO, + + [Description("Tokelau")] + TK, + + [Description("Guinea-Bissau")] + GW, + + [Description("Guam")] + GU, + + [Description("Guatemala")] + GT, + + [Description("South Georgia and the South Sandwich Islands")] + GS, + + [Description("Greece")] + GR, + + [Description("Equatorial Guinea")] + GQ, + + [Description("Guadeloupe")] + GP, + + [Description("Japan")] + JP, + + [Description("Guyana")] + GY, + + [Description("Guernsey")] + GG, + + [Description("French Guiana")] + GF, + + [Description("Georgia")] + GE, + + [Description("Grenada")] + GD, + + [Description("United Kingdom")] + GB, + + [Description("Gabon")] + GA, + + [Description("El Salvador")] + SV, + + [Description("Guinea")] + GN, + + [Description("Gambia")] + GM, + + [Description("Greenland")] + GL, + + [Description("Gibraltar")] + GI, + + [Description("Ghana")] + GH, + + [Description("Oman")] + OM, + + [Description("Tunisia")] + TN, + + [Description("Jordan")] + JO, + + [Description("Croatia")] + HR, + + [Description("Haiti")] + HT, + + [Description("Hungary")] + HU, + + [Description("Hong Kong")] + HK, + + [Description("Honduras")] + HN, + + [Description("Heard Island and McDonald Islands")] + HM, + + [Description("Venezuela")] + VE, + + [Description("Puerto Rico")] + PR, + + [Description("Palestinian Territory")] + PS, + + [Description("Palau")] + PW, + + [Description("Portugal")] + PT, + + [Description("Svalbard and Jan Mayen")] + SJ, + + [Description("Paraguay")] + PY, + + [Description("Iraq")] + IQ, + + [Description("Panama")] + PA, + + [Description("French Polynesia")] + PF, + + [Description("Papua New Guinea")] + PG, + + [Description("Peru")] + PE, + + [Description("Pakistan")] + PK, + + [Description("Philippines")] + PH, + + [Description("Pitcairn")] + PN, + + [Description("Poland")] + PL, + + [Description("Saint Pierre and Miquelon")] + PM, + + [Description("Zambia")] + ZM, + + [Description("Western Sahara")] + EH, + + [Description("Estonia")] + EE, + + [Description("Egypt")] + EG, + + [Description("South Africa")] + ZA, + + [Description("Ecuador")] + EC, + + [Description("Italy")] + IT, + + [Description("Vietnam")] + VN, + + [Description("Solomon Islands")] + SB, + + [Description("Ethiopia")] + ET, + + [Description("Somalia")] + SO, + + [Description("Zimbabwe")] + ZW, + + [Description("Saudi Arabia")] + SA, + + [Description("Spain")] + ES, + + [Description("Eritrea")] + ER, + + [Description("Montenegro")] + ME, + + [Description("Moldova")] + MD, + + [Description("Madagascar")] + MG, + + [Description("Saint Martin")] + MF, + + [Description("Morocco")] + MA, + + [Description("Monaco")] + MC, + + [Description("Uzbekistan")] + UZ, + + [Description("Myanmar")] + MM, + + [Description("Mali")] + ML, + + [Description("Macao")] + MO, + + [Description("Mongolia")] + MN, + + [Description("Marshall Islands")] + MH, + + [Description("North Macedonia")] + MK, + + [Description("Mauritius")] + MU, + + [Description("Malta")] + MT, + + [Description("Malawi")] + MW, + + [Description("Maldives")] + MV, + + [Description("Martinique")] + MQ, + + [Description("Northern Mariana Islands")] + MP, + + [Description("Montserrat")] + MS, + + [Description("Mauritania")] + MR, + + [Description("Isle of Man")] + IM, + + [Description("Uganda")] + UG, + + [Description("Tanzania")] + TZ, + + [Description("Malaysia")] + MY, + + [Description("Mexico")] + MX, + + [Description("Israel")] + IL, + + [Description("France")] + FR, + + [Description("British Indian Ocean Territory")] + IO, + + [Description("Saint Helena")] + SH, + + [Description("Finland")] + FI, + + [Description("Fiji")] + FJ, + + [Description("Falkland Islands")] + FK, + + [Description("Micronesia")] + FM, + + [Description("Faroe Islands")] + FO, + + [Description("Nicaragua")] + NI, + + [Description("Netherlands")] + NL, + + [Description("Norway")] + NO, + + [Description("Namibia")] + NA, + + [Description("Vanuatu")] + VU, + + [Description("New Caledonia")] + NC, + + [Description("Niger")] + NE, + + [Description("Norfolk Island")] + NF, + + [Description("Nigeria")] + NG, + + [Description("New Zealand")] + NZ, + + [Description("Nepal")] + NP, + + [Description("Nauru")] + NR, + + [Description("Niue")] + NU, + + [Description("Cook Islands")] + CK, + + [Description("Kosovo")] + XK, + + [Description("Ivory Coast")] + CI, + + [Description("Switzerland")] + CH, + + [Description("Colombia")] + CO, + + [Description("China")] + CN, + + [Description("Cameroon")] + CM, + + [Description("Chile")] + CL, + + [Description("Cocos Islands")] + CC, + + [Description("Canada")] + CA, + + [Description("Republic of the Congo")] + CG, + + [Description("Central African Republic")] + CF, + + [Description("Democratic Republic of the Congo")] + CD, + + [Description("Czech Republic")] + CZ, + + [Description("Cyprus")] + CY, + + [Description("Christmas Island")] + CX, + + [Description("Costa Rica")] + CR, + + [Description("Curacao")] + CW, + + [Description("Cabo Verde")] + CV, + + [Description("Cuba")] + CU, + + [Description("Eswatini")] + SZ, + + [Description("Syria")] + SY, + + [Description("Sint Maarten")] + SX, + + [Description("Kyrgyzstan")] + KG, + + [Description("Kenya")] + KE, + + [Description("South Sudan")] + SS, + + [Description("Suriname")] + SR, + + [Description("Kiribati")] + KI, + + [Description("Cambodia")] + KH, + + [Description("Saint Kitts and Nevis")] + KN, + + [Description("Comoros")] + KM, + + [Description("Sao Tome and Principe")] + ST, + + [Description("Slovakia")] + SK, + + [Description("South Korea")] + KR, + + [Description("Slovenia")] + SI, + + [Description("North Korea")] + KP, + + [Description("Kuwait")] + KW, + + [Description("Senegal")] + SN, + + [Description("San Marino")] + SM, + + [Description("Sierra Leone")] + SL, + + [Description("Seychelles")] + SC, + + [Description("Kazakhstan")] + KZ, + + [Description("Cayman Islands")] + KY, + + [Description("Singapore")] + SG, + + [Description("Sweden")] + SE, + + [Description("Sudan")] + SD, + + [Description("Dominican Republic")] + DO, + + [Description("Dominica")] + DM, + + [Description("Djibouti")] + DJ, + + [Description("Denmark")] + DK, + + [Description("British Virgin Islands")] + VG, + + [Description("Germany")] + DE, + + [Description("Yemen")] + YE, + + [Description("Algeria")] + DZ, + + [Description("United States")] + US, + + [Description("Uruguay")] + UY, + + [Description("Mayotte")] + YT, + + [Description("United States Minor Outlying Islands")] + UM, + + [Description("Lebanon")] + LB, + + [Description("Saint Lucia")] + LC, + + [Description("Laos")] + LA, + + [Description("Tuvalu")] + TV, + + [Description("Taiwan")] + TW, + + [Description("Trinidad and Tobago")] + TT, + + [Description("Turkey")] + TR, + + [Description("Sri Lanka")] + LK, + + [Description("Liechtenstein")] + LI, + + [Description("Latvia")] + LV, + + [Description("Tonga")] + TO, + + [Description("Lithuania")] + LT, + + [Description("Luxembourg")] + LU, + + [Description("Liberia")] + LR, + + [Description("Lesotho")] + LS, + + [Description("Thailand")] + TH, + + [Description("French Southern Territories")] + TF, + + [Description("Togo")] + TG, + + [Description("Chad")] + TD, + + [Description("Turks and Caicos Islands")] + TC, + + [Description("Libya")] + LY, + + [Description("Vatican")] + VA, + + [Description("Saint Vincent and the Grenadines")] + VC, + + [Description("United Arab Emirates")] + AE, + + [Description("Andorra")] + AD, + + [Description("Antigua and Barbuda")] + AG, + + [Description("Afghanistan")] + AF, + + [Description("Anguilla")] + AI, + + [Description("U.S. Virgin Islands")] + VI, + + [Description("Iceland")] + IS, + + [Description("Iran")] + IR, + + [Description("Armenia")] + AM, + + [Description("Albania")] + AL, + + [Description("Angola")] + AO, + + [Description("Antarctica")] + AQ, + + [Description("American Samoa")] + AS, + + [Description("Argentina")] + AR, + + [Description("Australia")] + AU, + + [Description("Austria")] + AT, + + [Description("Aruba")] + AW, + + [Description("India")] + IN, + + [Description("Aland Islands")] + AX, + + [Description("Azerbaijan")] + AZ, + + [Description("Ireland")] + IE, + + [Description("Indonesia")] + ID, + + [Description("Ukraine")] + UA, + + [Description("Qatar")] + QA, + + [Description("Mozambique")] + MZ, + } +} diff --git a/osu.Game/Users/CountryStatistics.cs b/osu.Game/Users/CountryStatistics.cs index e784630d56..03d455bc04 100644 --- a/osu.Game/Users/CountryStatistics.cs +++ b/osu.Game/Users/CountryStatistics.cs @@ -9,11 +9,8 @@ namespace osu.Game.Users { public class CountryStatistics { - [JsonProperty] - public Country Country; - [JsonProperty(@"code")] - public string FlagName; + public CountryCode Code; [JsonProperty(@"active_users")] public long ActiveUsers; diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index 483106d3f4..155f63dc18 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -14,13 +14,13 @@ namespace osu.Game.Users.Drawables [LongRunningLoad] public class DrawableAvatar : Sprite { - private readonly APIUser user; + private readonly IUser user; /// /// A simple, non-interactable avatar sprite for the specified user. /// /// The user. A null value will get a placeholder avatar. - public DrawableAvatar(APIUser user = null) + public DrawableAvatar(IUser user = null) { this.user = user; @@ -33,10 +33,10 @@ namespace osu.Game.Users.Drawables [BackgroundDependencyLoader] private void load(LargeTextureStore textures) { - if (user != null && user.Id > 1) + if (user != null && user.OnlineID > 1) // TODO: The fallback here should not need to exist. Users should be looked up and populated via UserLookupCache or otherwise // in remaining cases where this is required (chat tabs, local leaderboard), at which point this should be removed. - Texture = textures.Get(user.AvatarUrl ?? $@"https://a.ppy.sh/{user.Id}"); + Texture = textures.Get((user as APIUser)?.AvatarUrl ?? $@"https://a.ppy.sh/{user.OnlineID}"); Texture ??= textures.Get(@"Online/avatar-guest"); } diff --git a/osu.Game/Users/Drawables/DrawableFlag.cs b/osu.Game/Users/Drawables/DrawableFlag.cs index e5ac8fa257..253835b415 100644 --- a/osu.Game/Users/Drawables/DrawableFlag.cs +++ b/osu.Game/Users/Drawables/DrawableFlag.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; @@ -14,13 +15,13 @@ namespace osu.Game.Users.Drawables { public class DrawableFlag : Sprite, IHasTooltip { - private readonly Country country; + private readonly CountryCode countryCode; - public LocalisableString TooltipText => country?.FullName; + public LocalisableString TooltipText => countryCode == CountryCode.Unknown ? string.Empty : countryCode.GetDescription(); - public DrawableFlag(Country country) + public DrawableFlag(CountryCode countryCode) { - this.country = country; + this.countryCode = countryCode; } [BackgroundDependencyLoader] @@ -29,7 +30,8 @@ namespace osu.Game.Users.Drawables if (ts == null) throw new ArgumentNullException(nameof(ts)); - Texture = ts.Get($@"Flags/{country?.FlagName ?? @"__"}") ?? ts.Get(@"Flags/__"); + string textureName = countryCode == CountryCode.Unknown ? "__" : countryCode.ToString(); + Texture = ts.Get($@"Flags/{textureName}") ?? ts.Get(@"Flags/__"); } } } diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index fd59bf305d..9b101131b3 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -13,18 +13,18 @@ using osu.Game.Overlays; namespace osu.Game.Users.Drawables { - public class UpdateableFlag : ModelBackedDrawable + public class UpdateableFlag : ModelBackedDrawable { - public Country Country + public CountryCode CountryCode { get => Model; set => Model = value; } /// - /// Whether to show a place holder on null country. + /// Whether to show a place holder on unknown country. /// - public bool ShowPlaceholderOnNull = true; + public bool ShowPlaceholderOnUnknown = true; /// /// Perform an action in addition to showing the country ranking. @@ -32,14 +32,14 @@ namespace osu.Game.Users.Drawables /// public Action Action; - public UpdateableFlag(Country country = null) + public UpdateableFlag(CountryCode countryCode = CountryCode.Unknown) { - Country = country; + CountryCode = countryCode; } - protected override Drawable CreateDrawable(Country country) + protected override Drawable CreateDrawable(CountryCode countryCode) { - if (country == null && !ShowPlaceholderOnNull) + if (countryCode == CountryCode.Unknown && !ShowPlaceholderOnUnknown) return null; return new Container @@ -47,7 +47,7 @@ namespace osu.Game.Users.Drawables RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new DrawableFlag(country) + new DrawableFlag(countryCode) { RelativeSizeAxes = Axes.Both }, @@ -62,7 +62,7 @@ namespace osu.Game.Users.Drawables protected override bool OnClick(ClickEvent e) { Action?.Invoke(); - rankingsOverlay?.ShowCountry(Country); + rankingsOverlay?.ShowCountry(CountryCode); return true; } } diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs index 7d475c545a..a4cba8b920 100644 --- a/osu.Game/Users/ExtendedUserPanel.cs +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -53,7 +53,7 @@ namespace osu.Game.Users protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar(User, false); - protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) + protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.CountryCode) { Size = new Vector2(36, 26), Action = Action, diff --git a/osu.Game/Users/IUser.cs b/osu.Game/Users/IUser.cs index 7a233b5d8b..b7f545f68b 100644 --- a/osu.Game/Users/IUser.cs +++ b/osu.Game/Users/IUser.cs @@ -10,6 +10,8 @@ namespace osu.Game.Users { string Username { get; } + CountryCode CountryCode { get; } + bool IsBot { get; } bool IEquatable.Equals(IUser? other) diff --git a/osu.Game/Utils/BatteryInfo.cs b/osu.Game/Utils/BatteryInfo.cs index dd9b695e1f..ef75857a26 100644 --- a/osu.Game/Utils/BatteryInfo.cs +++ b/osu.Game/Utils/BatteryInfo.cs @@ -9,10 +9,16 @@ namespace osu.Game.Utils public abstract class BatteryInfo { /// - /// The charge level of the battery, from 0 to 1. + /// The charge level of the battery, from 0 to 1, or null if a battery isn't present. /// - public abstract double ChargeLevel { get; } + public abstract double? ChargeLevel { get; } - public abstract bool IsCharging { get; } + /// + /// Whether the current power source is the battery. + /// + /// + /// This is false when the device is charging or doesn't have a battery. + /// + public abstract bool OnBattery { get; } } } diff --git a/osu.Game/Utils/HumanizerUtils.cs b/osu.Game/Utils/HumanizerUtils.cs index 5b7c3630d9..0da346ed73 100644 --- a/osu.Game/Utils/HumanizerUtils.cs +++ b/osu.Game/Utils/HumanizerUtils.cs @@ -4,6 +4,7 @@ using System; using System.Globalization; using Humanizer; +using Humanizer.Localisation; namespace osu.Game.Utils { @@ -26,5 +27,27 @@ namespace osu.Game.Utils return input.Humanize(culture: new CultureInfo("en-US")); } } + + /// + /// Turns the current or provided timespan into a human readable sentence + /// + /// The date to be humanized + /// The maximum number of time units to return. Defaulted is 1 which means the largest unit is returned + /// The maximum unit of time to output. The default value is . The time units and will give approximations for time spans bigger 30 days by calculating with 365.2425 days a year and 30.4369 days a month. + /// The minimum unit of time to output. + /// Uses words instead of numbers if true. E.g. one day. + /// distance of time in words + public static string Humanize(TimeSpan input, int precision = 1, TimeUnit maxUnit = TimeUnit.Week, TimeUnit minUnit = TimeUnit.Millisecond, bool toWords = false) + { + // this works around https://github.com/xamarin/xamarin-android/issues/2012 and https://github.com/Humanizr/Humanizer/issues/690#issuecomment-368536282 + try + { + return input.Humanize(precision: precision, maxUnit: maxUnit, minUnit: minUnit); + } + catch (ArgumentException) + { + return input.Humanize(culture: new CultureInfo("en-US"), precision: precision, maxUnit: maxUnit, minUnit: minUnit); + } + } } } diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 33dc548e9a..8c39a2d15a 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -9,6 +9,7 @@ using System.Net; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Configuration; using osu.Framework.Logging; using osu.Framework.Statistics; using osu.Game.Beatmaps; @@ -112,8 +113,8 @@ namespace osu.Game.Utils scope.Contexts[@"config"] = new { - Game = game.Dependencies.Get().GetLoggableState() - // TODO: add framework config here. needs some consideration on how to expose. + Game = game.Dependencies.Get().GetCurrentConfigurationForLogging(), + Framework = game.Dependencies.Get().GetCurrentConfigurationForLogging(), }; game.Dependencies.Get().Run(realm => diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 06b022ea44..22474c0592 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -20,28 +20,28 @@ - + - - - - - - + + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + - + diff --git a/osu.iOS.props b/osu.iOS.props index 9085205bbc..cf70b65578 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,8 +61,8 @@ - - + + @@ -81,14 +81,12 @@ - - - - + + - + diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 452b573389..ecbea42d74 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -43,9 +43,9 @@ namespace osu.iOS private class IOSBatteryInfo : BatteryInfo { - public override double ChargeLevel => Battery.ChargeLevel; + public override double? ChargeLevel => Battery.ChargeLevel; - public override bool IsCharging => Battery.PowerSource != BatteryPowerSource.Battery; + public override bool OnBattery => Battery.PowerSource == BatteryPowerSource.Battery; } } } diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 0794095854..44df495929 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -350,6 +350,7 @@ PM RGB RNG + SDL SHA SRGB TK @@ -806,9 +807,9 @@ See the LICENCE file in the repository root for full licence text. True True - True True True + True True True True