diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6444127594..985fc09df3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -27,10 +27,10 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2021.725.0", + "version": "2021.1210.0", "commands": [ "localisation" ] } } -} \ No newline at end of file +} diff --git a/.idea/.idea.osu.Android/.idea/.name b/.idea/.idea.osu.Android/.idea/.name new file mode 100644 index 0000000000..86363b495c --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/.name @@ -0,0 +1 @@ +osu.Android \ No newline at end of file diff --git a/.idea/.idea.osu.Android/.idea/indexLayout.xml b/.idea/.idea.osu.Android/.idea/indexLayout.xml new file mode 100644 index 0000000000..7b08163ceb --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Android/.idea/misc.xml b/.idea/.idea.osu.Android/.idea/misc.xml new file mode 100644 index 0000000000..1d8c84d0af --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml new file mode 100644 index 0000000000..4bb9f4d2a0 --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Android/.idea/vcs.xml b/.idea/.idea.osu.Android/.idea/vcs.xml new file mode 100644 index 0000000000..94a25f7f4c --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/misc.xml b/.idea/.idea.osu/.idea/misc.xml new file mode 100644 index 0000000000..1d8c84d0af --- /dev/null +++ b/.idea/.idea.osu/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/modules.xml b/.idea/.idea.osu/.idea/modules.xml deleted file mode 100644 index 0360fdbc5e..0000000000 --- a/.idea/.idea.osu/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu/.idea/projectSettingsUpdater.xml index 7515e76054..4bb9f4d2a0 100644 --- a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml +++ b/.idea/.idea.osu/.idea/projectSettingsUpdater.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e14be20642..ae2bdd2e82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing Guidelines -Thank you for showing interest in the development of osu!lazer! We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. +Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. These are not "official rules" *per se*, but following them will help everyone deal with things in the most efficient manner. @@ -32,7 +32,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in * **Provide more information when asked to do so.** - Sometimes when a bug is more elusive or complicated, none of the information listed above will pinpoint a concrete cause of the problem. In this case we will most likely ask you for additional info, such as a Windows Event Log dump or a copy of your local lazer database (`client.db`). Providing that information is beneficial to both parties - we can track down the problem better, and hopefully fix it for you at some point once we know where it is! + Sometimes when a bug is more elusive or complicated, none of the information listed above will pinpoint a concrete cause of the problem. In this case we will most likely ask you for additional info, such as a Windows Event Log dump or a copy of your local osu! database (`client.db`). Providing that information is beneficial to both parties - we can track down the problem better, and hopefully fix it for you at some point once we know where it is! * **When submitting a feature proposal, please describe it in the most understandable way you can.** @@ -54,7 +54,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in We also welcome pull requests from unaffiliated contributors. The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues that you can work on; we also mark issues that we think would be good for newcomers with the [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label. -However, do keep in mind that the core team is committed to bringing osu!lazer up to par with stable first and foremost, so depending on what your contribution concerns, it might not be merged and released right away. Our approach to managing issues and their priorities is described [in the wiki](https://github.com/ppy/osu/wiki/Project-management). +However, do keep in mind that the core team is committed to bringing osu!(lazer) up to par with osu!(stable) first and foremost, so depending on what your contribution concerns, it might not be merged and released right away. Our approach to managing issues and their priorities is described [in the wiki](https://github.com/ppy/osu/wiki/Project-management). Here are some key things to note before jumping in: @@ -128,7 +128,7 @@ Here are some key things to note before jumping in: * **Don't mistake criticism of code for criticism of your person.** - As mentioned before, we are highly committed to quality when it comes to the lazer project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience, and don't treat it as a personal attack. + As mentioned before, we are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience, and don't treat it as a personal attack. * **Feel free to reach out for help.** diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index c567adc0ae..e96ad48325 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -13,3 +13,5 @@ M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.H M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead. M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IQueryable,NotificationCallbackDelegate) instead. M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IList,NotificationCallbackDelegate) instead. +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. diff --git a/README.md b/README.md index 786ce2589d..b1dfcab416 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A free-to-win rhythm game. Rhythm is just a *click* away! -The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the codename "*lazer*". As in sharper than cutting-edge. +The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge. ## Status @@ -31,7 +31,7 @@ If you are looking to install or test osu! without setting up a development envi **Latest build:** -| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) +| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.15+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | ------------- | ------------- | ------------- | ------------- | ------------- | - The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets. @@ -48,7 +48,7 @@ You can see some examples of custom rulesets by visiting the [custom ruleset dir Please make sure you have the following prerequisites: -- A desktop platform with the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) or higher installed. +- A desktop platform with the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) installed. - When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). - When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). - When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs index 536fdfc6df..5973db908c 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.EmptyFreeform.Tests public class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] - private void load(GameHost host, OsuGameBase gameBase) + private void load() { Children = new Drawable[] { diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs index 3cdf44e6f1..b75a5ec187 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Pippidon.Tests public class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] - private void load(GameHost host, OsuGameBase gameBase) + private void load() { Children = new Drawable[] { diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs index 4d3f5086d9..ffe921b54c 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.EmptyScrolling.Tests public class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] - private void load(GameHost host, OsuGameBase gameBase) + private void load() { Children = new Drawable[] { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs index 3cdf44e6f1..b75a5ec187 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Pippidon.Tests public class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] - private void load(GameHost host, OsuGameBase gameBase) + private void load() { Children = new Drawable[] { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs index 0e50030162..ab8c6bb2e9 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs @@ -7,7 +7,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -28,7 +27,7 @@ namespace osu.Game.Rulesets.Pippidon.UI private PippidonCharacter pippidon; [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load() { AddRangeInternal(new Drawable[] { diff --git a/osu.Android.props b/osu.Android.props index eff0eed278..b2e3b32916 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index fec96c9165..c9fb539d8a 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -17,7 +17,7 @@ using osu.Game.Database; namespace osu.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance, Exported = true)] + [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser)] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*", DataMimeType = "*/*")] @@ -47,11 +47,6 @@ namespace osu.Android protected override void OnCreate(Bundle savedInstanceState) { - // The default current directory on android is '/'. - // On some devices '/' maps to the app data directory. On others it maps to the root of the internal storage. - // In order to have a consistent current directory on all devices the full path of the app data directory is set as the current directory. - System.Environment.CurrentDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal); - base.OnCreate(savedInstanceState); // OnNewIntent() only fires for an activity if it's *re-launched* while it's on top of the activity stack. diff --git a/osu.Android/Properties/AndroidManifest.xml b/osu.Android/Properties/AndroidManifest.xml index e717bab310..165a64a424 100644 --- a/osu.Android/Properties/AndroidManifest.xml +++ b/osu.Android/Properties/AndroidManifest.xml @@ -1,11 +1,5 @@  - - - - - - \ No newline at end of file diff --git a/osu.Android/Properties/AssemblyInfo.cs b/osu.Android/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..c0ba324d6e --- /dev/null +++ b/osu.Android/Properties/AssemblyInfo.cs @@ -0,0 +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 Android; +using Android.App; + +// used for AndroidBatteryInfo +[assembly: UsesPermission(Manifest.Permission.BatteryStats)] diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index b2599535ae..fc50ca9fa1 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -29,6 +29,7 @@ + diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs new file mode 100644 index 0000000000..d6ef390a8f --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.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.Desktop.LegacyIpc +{ + /// + /// A difficulty calculation request from the legacy client. + /// + /// + /// Synchronise any changes with osu!stable. + /// + public class LegacyIpcDifficultyCalculationRequest + { + public string BeatmapFile { get; set; } + public int RulesetId { get; set; } + public int Mods { get; set; } + } +} diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs new file mode 100644 index 0000000000..7b9fae5797 --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.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. + +namespace osu.Desktop.LegacyIpc +{ + /// + /// A difficulty calculation response returned to the legacy client. + /// + /// + /// Synchronise any changes with osu!stable. + /// + public class LegacyIpcDifficultyCalculationResponse + { + public double StarRating { get; set; } + } +} diff --git a/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs b/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs new file mode 100644 index 0000000000..0fa60e2068 --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyIpcMessage.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.Platform; +using Newtonsoft.Json.Linq; + +namespace osu.Desktop.LegacyIpc +{ + /// + /// An that can be used to communicate to and from legacy clients. + /// + /// In order to deserialise types at either end, types must be serialised as their , + /// however this cannot be done since osu!stable and osu!lazer live in two different assemblies. + ///
+ /// To get around this, this class exists which serialises a payload () as an type, + /// which can be deserialised at either end because it is part of the core library (mscorlib / System.Private.CorLib). + /// The payload contains the data to be sent over the IPC channel. + ///
+ /// At either end, Json.NET deserialises the payload into a which is manually converted back into the expected type, + /// which then further contains another representing the data sent over the IPC channel whose type can likewise be lazily matched through + /// . + ///
+ ///
+ /// + /// Synchronise any changes with osu-stable. + /// + public class LegacyIpcMessage : IpcMessage + { + public LegacyIpcMessage() + { + // Types/assemblies are not inter-compatible, so always serialise/deserialise into objects. + base.Type = typeof(object).FullName; + } + + public new string Type => base.Type; // Hide setter. + + public new object Value + { + get => base.Value; + set => base.Value = new Data + { + MessageType = value.GetType().Name, + MessageData = value + }; + } + + public class Data + { + public string MessageType { get; set; } + public object MessageData { get; set; } + } + } +} diff --git a/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs b/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs new file mode 100644 index 0000000000..97a4c57bf0 --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs @@ -0,0 +1,121 @@ +// 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 Newtonsoft.Json.Linq; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; + +#nullable enable + +namespace osu.Desktop.LegacyIpc +{ + /// + /// Provides IPC to legacy osu! clients. + /// + public class LegacyTcpIpcProvider : TcpIpcProvider + { + private static readonly Logger logger = Logger.GetLogger("legacy-ipc"); + + public LegacyTcpIpcProvider() + : base(45357) + { + MessageReceived += msg => + { + try + { + logger.Add("Processing legacy IPC message..."); + logger.Add($" {msg.Value}", LogLevel.Debug); + + // See explanation in LegacyIpcMessage for why this is done this way. + var legacyData = ((JObject)msg.Value).ToObject(); + object value = parseObject((JObject)legacyData!.MessageData, legacyData.MessageType); + + return new LegacyIpcMessage + { + Value = onLegacyIpcMessageReceived(value) + }; + } + catch (Exception ex) + { + logger.Add($"Processing IPC message failed: {msg.Value}", exception: ex); + return null; + } + }; + } + + private object parseObject(JObject value, string type) + { + switch (type) + { + case nameof(LegacyIpcDifficultyCalculationRequest): + return value.ToObject() + ?? throw new InvalidOperationException($"Failed to parse request {value}"); + + case nameof(LegacyIpcDifficultyCalculationResponse): + return value.ToObject() + ?? throw new InvalidOperationException($"Failed to parse request {value}"); + + default: + throw new ArgumentException($"Unsupported object type {type}"); + } + } + + private object onLegacyIpcMessageReceived(object message) + { + switch (message) + { + case LegacyIpcDifficultyCalculationRequest req: + try + { + var ruleset = getLegacyRulesetFromID(req.RulesetId); + + Mod[] mods = ruleset.ConvertFromLegacyMods((LegacyMods)req.Mods).ToArray(); + WorkingBeatmap beatmap = new FlatFileWorkingBeatmap(req.BeatmapFile, _ => ruleset); + + return new LegacyIpcDifficultyCalculationResponse + { + StarRating = ruleset.CreateDifficultyCalculator(beatmap).Calculate(mods).StarRating + }; + } + catch + { + return new LegacyIpcDifficultyCalculationResponse(); + } + + default: + throw new ArgumentException($"Unsupported message type {message}"); + } + } + + private static Ruleset getLegacyRulesetFromID(int rulesetId) + { + switch (rulesetId) + { + case 0: + return new OsuRuleset(); + + case 1: + return new TaikoRuleset(); + + case 2: + return new CatchRuleset(); + + case 3: + return new ManiaRuleset(); + + default: + throw new ArgumentException("Invalid ruleset id"); + } + } + } +} diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 645ea66654..b234207848 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -70,7 +70,9 @@ namespace osu.Desktop if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath)) return stableInstallPath; } - catch { } + catch + { + } } stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); @@ -113,7 +115,7 @@ namespace osu.Desktop base.LoadComplete(); if (!noVersionOverlay) - LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, Add); + LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, ScreenContainer.Add); LoadComponentAsync(new DiscordRichPresence(), Add); diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 898f7d5105..7ec7d53a7e 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using osu.Desktop.LegacyIpc; using osu.Framework; using osu.Framework.Development; using osu.Framework.Logging; @@ -18,8 +19,10 @@ namespace osu.Desktop { private const string base_game_name = @"osu"; + private static LegacyTcpIpcProvider legacyIpc; + [STAThread] - public static int Main(string[] args) + public static void Main(string[] args) { // Back up the cwd before DesktopGameHost changes it string cwd = Environment.CurrentDirectory; @@ -69,14 +72,28 @@ namespace osu.Desktop throw new TimeoutException(@"IPC took too long to send"); } - return 0; + return; } // we want to allow multiple instances to be started when in debug. if (!DebugUtils.IsDebugBuild) { Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); - return 0; + return; + } + } + + if (host.IsPrimaryInstance) + { + try + { + Logger.Log("Starting legacy IPC provider..."); + legacyIpc = new LegacyTcpIpcProvider(); + legacyIpc.Bind(); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to start legacy IPC provider"); } } @@ -84,8 +101,6 @@ namespace osu.Desktop host.Run(new TournamentGame()); else host.Run(new OsuGameDesktop(args)); - - return 0; } } diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs index 01458b4c37..8f3ad853dc 100644 --- a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs @@ -73,10 +73,10 @@ namespace osu.Desktop.Security } [BackgroundDependencyLoader] - private void load(OsuColour colours, NotificationOverlay notificationOverlay) + private void load(OsuColour colours) { Icon = FontAwesome.Solid.ShieldAlt; - IconBackgound.Colour = colours.YellowDark; + IconBackground.Colour = colours.YellowDark; } } } diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs index dbfd170ea1..4acaf61cea 100644 --- a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs @@ -14,6 +14,7 @@ namespace osu.Desktop.Windows { private Bindable disableWinKey; private IBindable localUserPlaying; + private IBindable isActive; [Resolved] private GameHost host { get; set; } @@ -24,13 +25,16 @@ namespace osu.Desktop.Windows localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); localUserPlaying.BindValueChanged(_ => updateBlocking()); + isActive = host.IsActive.GetBoundCopy(); + isActive.BindValueChanged(_ => updateBlocking()); + disableWinKey = config.GetBindable(OsuSetting.GameplayDisableWinKey); disableWinKey.BindValueChanged(_ => updateBlocking(), true); } private void updateBlocking() { - bool shouldDisable = disableWinKey.Value && localUserPlaying.Value; + bool shouldDisable = isActive.Value && disableWinKey.Value && localUserPlaying.Value; if (shouldDisable) host.InputThread.Scheduler.Add(WindowsKey.Disable); diff --git a/osu.Desktop/Windows/WindowsKey.cs b/osu.Desktop/Windows/WindowsKey.cs index f19d741107..fdca2028d3 100644 --- a/osu.Desktop/Windows/WindowsKey.cs +++ b/osu.Desktop/Windows/WindowsKey.cs @@ -4,6 +4,8 @@ using System; using System.Runtime.InteropServices; +// ReSharper disable IdentifierTypo + namespace osu.Desktop.Windows { internal class WindowsKey diff --git a/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs index d918305f3d..d8b729576d 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs @@ -2,13 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using Android.App; -using Android.Content.PM; using osu.Framework.Android; using osu.Game.Tests; namespace osu.Game.Rulesets.Catch.Tests.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.SensorLandscape, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)] + [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] public class MainActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuTestBrowser(); diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist index 5115746cbb..3ba1886d98 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs index 33fdcdaf1e..baca8166d1 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs @@ -14,7 +14,6 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - [Timeout(10000)] public class CatchBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; @@ -27,6 +26,7 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase("hardrock-repeat-slider", new[] { typeof(CatchModHardRock) })] [TestCase("hardrock-spinner", new[] { typeof(CatchModHardRock) })] [TestCase("right-bound-hr-offset", new[] { typeof(CatchModHardRock) })] + [TestCase("basic-hyperdash")] public new void Test(string name, params Type[] mods) => base.Test(name, mods); protected override IEnumerable CreateConvertValue(HitObject hitObject) @@ -70,6 +70,7 @@ namespace osu.Game.Rulesets.Catch.Tests HitObject = hitObject; startTime = 0; position = 0; + hyperDash = false; } private double startTime; @@ -88,8 +89,17 @@ namespace osu.Game.Rulesets.Catch.Tests set => position = value; } + private bool hyperDash; + + public bool HyperDash + { + get => (HitObject as PalpableCatchHitObject)?.HyperDash ?? hyperDash; + set => hyperDash = value; + } + public bool Equals(ConvertValue other) => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) - && Precision.AlmostEquals(Position, other.Position, conversion_lenience); + && Precision.AlmostEquals(Position, other.Position, conversion_lenience) + && HyperDash == other.HyperDash; } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index 459b8e1f6f..23f6222eb6 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestJuicestream() + public void TestJuiceStream() { AddStep("hit juicestream", () => spawnJuiceStream(true)); AddUntilStep("wait for completion", () => playfieldIsEmpty); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 943adbef52..12b98dc93c 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -37,20 +37,20 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("show hyperdash droplet", () => SetContents(_ => createDrawableDroplet(true))); } - private Drawable createDrawableFruit(int indexInBeatmap, bool hyperdash = false) => + private Drawable createDrawableFruit(int indexInBeatmap, bool hyperDash = false) => new TestDrawableCatchHitObjectSpecimen(new DrawableFruit(new Fruit { IndexInBeatmap = indexInBeatmap, - HyperDashBindable = { Value = hyperdash } + HyperDashBindable = { Value = hyperDash } })); private Drawable createDrawableBanana() => new TestDrawableCatchHitObjectSpecimen(new DrawableBanana(new Banana())); - private Drawable createDrawableDroplet(bool hyperdash = false) => + private Drawable createDrawableDroplet(bool hyperDash = false) => new TestDrawableCatchHitObjectSpecimen(new DrawableDroplet(new Droplet { - HyperDashBindable = { Value = hyperdash } + HyperDashBindable = { Value = hyperDash } })); private Drawable createDrawableTinyDroplet() => new TestDrawableCatchHitObjectSpecimen(new DrawableTinyDroplet(new TinyDroplet())); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 70b2c8c82a..14a4d02396 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -174,7 +174,7 @@ namespace osu.Game.Rulesets.Catch.Tests private Drawable setupSkinHierarchy(Drawable child, ISkin skin) { - var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info)); + var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.CreateInfo())); var testSkinProvider = new SkinProvidingContainer(skin); var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider)); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceAttributes.cs new file mode 100644 index 0000000000..1335fc2d23 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceAttributes.cs @@ -0,0 +1,11 @@ +// 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; + +namespace osu.Game.Rulesets.Catch.Difficulty +{ + public class CatchPerformanceAttributes : PerformanceAttributes + { + } +} diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 439890dac2..8cdbe500f0 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty { } - public override double Calculate(Dictionary categoryDifficulty = null) + public override PerformanceAttributes Calculate() { mods = Score.Mods; @@ -44,15 +44,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty // Longer maps are worth more. "Longer" means how many hits there are which can contribute to combo int numTotalHits = totalComboHits(); - // Longer maps are worth more double lengthBonus = 0.95 + 0.3 * Math.Min(1.0, numTotalHits / 2500.0) + (numTotalHits > 2500 ? Math.Log10(numTotalHits / 2500.0) * 0.475 : 0.0); - - // Longer maps are worth more value *= lengthBonus; - // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available value *= Math.Pow(0.97, misses); // Combo scaling @@ -80,17 +76,17 @@ namespace osu.Game.Rulesets.Catch.Difficulty } if (mods.Any(m => m is ModFlashlight)) - // Apply length bonus again if flashlight is on simply because it becomes a lot harder on longer maps. value *= 1.35 * lengthBonus; - // Scale the aim value with accuracy _slightly_ value *= Math.Pow(accuracy(), 5.5); - // Custom multipliers for NoFail. SpunOut is not applicable. if (mods.Any(m => m is ModNoFail)) value *= 0.90; - return value; + return new CatchPerformanceAttributes + { + Total = value + }; } private double accuracy() => totalHits() == 0 ? 0 : Math.Clamp((double)totalSuccessfulHits() / totalHits(), 0, 1); diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs index 8cb0804ab7..dd5835b4ed 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs @@ -52,16 +52,25 @@ namespace osu.Game.Rulesets.Catch.Edit return true; } - public override bool HandleFlip(Direction direction) + public override bool HandleFlip(Direction direction, bool flipOverOrigin) { + if (SelectedItems.Count == 0) + return false; + + // This could be implemented in the future if there's a requirement for it. + if (direction == Direction.Vertical) + return false; + var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems); bool changed = false; + EditorBeatmap.PerformOnSelection(h => { if (h is CatchHitObject catchObject) - changed |= handleFlip(selectionRange, catchObject); + changed |= handleFlip(selectionRange, catchObject, flipOverOrigin); }); + return changed; } @@ -116,7 +125,7 @@ namespace osu.Game.Rulesets.Catch.Edit return Math.Clamp(deltaX, lowerBound, upperBound); } - private bool handleFlip(PositionRange selectionRange, CatchHitObject hitObject) + private bool handleFlip(PositionRange selectionRange, CatchHitObject hitObject, bool flipOverOrigin) { switch (hitObject) { @@ -124,7 +133,7 @@ namespace osu.Game.Rulesets.Catch.Edit return false; case JuiceStream juiceStream: - juiceStream.OriginalX = selectionRange.GetFlippedPosition(juiceStream.OriginalX); + juiceStream.OriginalX = getFlippedPosition(juiceStream.OriginalX); foreach (var point in juiceStream.Path.ControlPoints) point.Position *= new Vector2(-1, 1); @@ -133,9 +142,11 @@ namespace osu.Game.Rulesets.Catch.Edit return true; default: - hitObject.OriginalX = selectionRange.GetFlippedPosition(hitObject.OriginalX); + hitObject.OriginalX = getFlippedPosition(hitObject.OriginalX); return true; } + + float getFlippedPosition(float original) => flipOverOrigin ? CatchPlayfield.WIDTH - original : selectionRange.GetFlippedPosition(original); } } } diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json index b65d54a565..07ceb199bd 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json @@ -3,135 +3,168 @@ "StartTime": 500, "Objects": [{ "StartTime": 500, - "Position": 96 + "Position": 96, + "HyperDash": false }, { "StartTime": 562, - "Position": 100.84 + "Position": 100.84, + "HyperDash": false }, { "StartTime": 625, - "Position": 125 + "Position": 125, + "HyperDash": false }, { "StartTime": 687, - "Position": 152.84 + "Position": 152.84, + "HyperDash": false }, { "StartTime": 750, - "Position": 191 + "Position": 191, + "HyperDash": false }, { "StartTime": 812, - "Position": 212.84 + "Position": 212.84, + "HyperDash": false }, { "StartTime": 875, - "Position": 217 + "Position": 217, + "HyperDash": false }, { "StartTime": 937, - "Position": 234.84 + "Position": 234.84, + "HyperDash": false }, { "StartTime": 1000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 1062, - "Position": 267.84 + "Position": 267.84, + "HyperDash": false }, { "StartTime": 1125, - "Position": 284 + "Position": 284, + "HyperDash": false }, { "StartTime": 1187, - "Position": 311.84 + "Position": 311.84, + "HyperDash": false }, { "StartTime": 1250, - "Position": 350 + "Position": 350, + "HyperDash": false }, { "StartTime": 1312, - "Position": 359.84 + "Position": 359.84, + "HyperDash": false }, { "StartTime": 1375, - "Position": 367 + "Position": 367, + "HyperDash": false }, { "StartTime": 1437, - "Position": 400.84 + "Position": 400.84, + "HyperDash": false }, { "StartTime": 1500, - "Position": 416 + "Position": 416, + "HyperDash": false }, { "StartTime": 1562, - "Position": 377.159973 + "Position": 377.159973, + "HyperDash": false }, { "StartTime": 1625, - "Position": 367 + "Position": 367, + "HyperDash": false }, { "StartTime": 1687, - "Position": 374.159973 + "Position": 374.159973, + "HyperDash": false }, { "StartTime": 1750, - "Position": 353 + "Position": 353, + "HyperDash": false }, { "StartTime": 1812, - "Position": 329.159973 + "Position": 329.159973, + "HyperDash": false }, { "StartTime": 1875, - "Position": 288 + "Position": 288, + "HyperDash": false }, { "StartTime": 1937, - "Position": 259.159973 + "Position": 259.159973, + "HyperDash": false }, { "StartTime": 2000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 2058, - "Position": 232.44 + "Position": 232.44, + "HyperDash": false }, { "StartTime": 2116, - "Position": 222.879974 + "Position": 222.879974, + "HyperDash": false }, { "StartTime": 2174, - "Position": 185.319992 + "Position": 185.319992, + "HyperDash": false }, { "StartTime": 2232, - "Position": 177.76001 + "Position": 177.76001, + "HyperDash": false }, { "StartTime": 2290, - "Position": 162.200012 + "Position": 162.200012, + "HyperDash": false }, { "StartTime": 2348, - "Position": 158.639984 + "Position": 158.639984, + "HyperDash": false }, { "StartTime": 2406, - "Position": 111.079994 + "Position": 111.079994, + "HyperDash": false }, { "StartTime": 2500, - "Position": 96 + "Position": 96, + "HyperDash": false } ] }, @@ -139,71 +172,88 @@ "StartTime": 3000, "Objects": [{ "StartTime": 3000, - "Position": 18 + "Position": 18, + "HyperDash": false }, { "StartTime": 3062, - "Position": 249 + "Position": 249, + "HyperDash": false }, { "StartTime": 3125, - "Position": 184 + "Position": 184, + "HyperDash": false }, { "StartTime": 3187, - "Position": 477 + "Position": 477, + "HyperDash": false }, { "StartTime": 3250, - "Position": 43 + "Position": 43, + "HyperDash": false }, { "StartTime": 3312, - "Position": 494 + "Position": 494, + "HyperDash": false }, { "StartTime": 3375, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 3437, - "Position": 30 + "Position": 30, + "HyperDash": false }, { "StartTime": 3500, - "Position": 11 + "Position": 11, + "HyperDash": false }, { "StartTime": 3562, - "Position": 239 + "Position": 239, + "HyperDash": false }, { "StartTime": 3625, - "Position": 505 + "Position": 505, + "HyperDash": false }, { "StartTime": 3687, - "Position": 353 + "Position": 353, + "HyperDash": false }, { "StartTime": 3750, - "Position": 136 + "Position": 136, + "HyperDash": false }, { "StartTime": 3812, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 3875, - "Position": 346 + "Position": 346, + "HyperDash": false }, { "StartTime": 3937, - "Position": 39 + "Position": 39, + "HyperDash": false }, { "StartTime": 4000, - "Position": 300 + "Position": 300, + "HyperDash": false } ] }, @@ -211,71 +261,88 @@ "StartTime": 4500, "Objects": [{ "StartTime": 4500, - "Position": 398 + "Position": 398, + "HyperDash": false }, { "StartTime": 4562, - "Position": 151 + "Position": 151, + "HyperDash": false }, { "StartTime": 4625, - "Position": 73 + "Position": 73, + "HyperDash": false }, { "StartTime": 4687, - "Position": 311 + "Position": 311, + "HyperDash": false }, { "StartTime": 4750, - "Position": 90 + "Position": 90, + "HyperDash": false }, { "StartTime": 4812, - "Position": 264 + "Position": 264, + "HyperDash": false }, { "StartTime": 4875, - "Position": 477 + "Position": 477, + "HyperDash": false }, { "StartTime": 4937, - "Position": 473 + "Position": 473, + "HyperDash": false }, { "StartTime": 5000, - "Position": 120 + "Position": 120, + "HyperDash": false }, { "StartTime": 5062, - "Position": 115 + "Position": 115, + "HyperDash": false }, { "StartTime": 5125, - "Position": 163 + "Position": 163, + "HyperDash": false }, { "StartTime": 5187, - "Position": 447 + "Position": 447, + "HyperDash": false }, { "StartTime": 5250, - "Position": 72 + "Position": 72, + "HyperDash": false }, { "StartTime": 5312, - "Position": 257 + "Position": 257, + "HyperDash": false }, { "StartTime": 5375, - "Position": 153 + "Position": 153, + "HyperDash": false }, { "StartTime": 5437, - "Position": 388 + "Position": 388, + "HyperDash": false }, { "StartTime": 5500, - "Position": 336 + "Position": 336, + "HyperDash": false } ] }, @@ -283,39 +350,48 @@ "StartTime": 6000, "Objects": [{ "StartTime": 6000, - "Position": 13 + "Position": 13, + "HyperDash": false }, { "StartTime": 6062, - "Position": 429 + "Position": 429, + "HyperDash": false }, { "StartTime": 6125, - "Position": 381 + "Position": 381, + "HyperDash": false }, { "StartTime": 6187, - "Position": 186 + "Position": 186, + "HyperDash": false }, { "StartTime": 6250, - "Position": 267 + "Position": 267, + "HyperDash": false }, { "StartTime": 6312, - "Position": 305 + "Position": 305, + "HyperDash": false }, { "StartTime": 6375, - "Position": 456 + "Position": 456, + "HyperDash": false }, { "StartTime": 6437, - "Position": 26 + "Position": 26, + "HyperDash": false }, { "StartTime": 6500, - "Position": 238 + "Position": 238, + "HyperDash": false } ] }, @@ -323,71 +399,88 @@ "StartTime": 7000, "Objects": [{ "StartTime": 7000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 7062, - "Position": 262.84 + "Position": 262.84, + "HyperDash": false }, { "StartTime": 7125, - "Position": 295 + "Position": 295, + "HyperDash": false }, { "StartTime": 7187, - "Position": 303.84 + "Position": 303.84, + "HyperDash": false }, { "StartTime": 7250, - "Position": 336 + "Position": 336, + "HyperDash": false }, { "StartTime": 7312, - "Position": 319.16 + "Position": 319.16, + "HyperDash": false }, { "StartTime": 7375, - "Position": 306 + "Position": 306, + "HyperDash": false }, { "StartTime": 7437, - "Position": 272.16 + "Position": 272.16, + "HyperDash": false }, { "StartTime": 7500, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 7562, - "Position": 255.84 + "Position": 255.84, + "HyperDash": false }, { "StartTime": 7625, - "Position": 300 + "Position": 300, + "HyperDash": false }, { "StartTime": 7687, - "Position": 320.84 + "Position": 320.84, + "HyperDash": false }, { "StartTime": 7750, - "Position": 336 + "Position": 336, + "HyperDash": false }, { "StartTime": 7803, - "Position": 319.04 + "Position": 319.04, + "HyperDash": false }, { "StartTime": 7857, - "Position": 283.76 + "Position": 283.76, + "HyperDash": false }, { "StartTime": 7910, - "Position": 265.8 + "Position": 265.8, + "HyperDash": false }, { "StartTime": 8000, - "Position": 256 + "Position": 256, + "HyperDash": false } ] }, @@ -395,167 +488,208 @@ "StartTime": 8500, "Objects": [{ "StartTime": 8500, - "Position": 32 + "Position": 32, + "HyperDash": false }, { "StartTime": 8562, - "Position": 21.8515015 + "Position": 21.8515015, + "HyperDash": false }, { "StartTime": 8625, - "Position": 44.5659637 + "Position": 44.5659637, + "HyperDash": false }, { "StartTime": 8687, - "Position": 33.3433228 + "Position": 33.3433228, + "HyperDash": false }, { "StartTime": 8750, - "Position": 63.58974 + "Position": 63.58974, + "HyperDash": false }, { "StartTime": 8812, - "Position": 71.23422 + "Position": 71.23422, + "HyperDash": false }, { "StartTime": 8875, - "Position": 62.7117844 + "Position": 62.7117844, + "HyperDash": false }, { "StartTime": 8937, - "Position": 65.52607 + "Position": 65.52607, + "HyperDash": false }, { "StartTime": 9000, - "Position": 101.81015 + "Position": 101.81015, + "HyperDash": false }, { "StartTime": 9062, - "Position": 134.47818 + "Position": 134.47818, + "HyperDash": false }, { "StartTime": 9125, - "Position": 141.414444 + "Position": 141.414444, + "HyperDash": false }, { "StartTime": 9187, - "Position": 164.1861 + "Position": 164.1861, + "HyperDash": false }, { "StartTime": 9250, - "Position": 176.600418 + "Position": 176.600418, + "HyperDash": false }, { "StartTime": 9312, - "Position": 184.293015 + "Position": 184.293015, + "HyperDash": false }, { "StartTime": 9375, - "Position": 212.2076 + "Position": 212.2076, + "HyperDash": false }, { "StartTime": 9437, - "Position": 236.438324 + "Position": 236.438324, + "HyperDash": false }, { "StartTime": 9500, - "Position": 237.2304 + "Position": 237.2304, + "HyperDash": false }, { "StartTime": 9562, - "Position": 241.253983 + "Position": 241.253983, + "HyperDash": false }, { "StartTime": 9625, - "Position": 233.950623 + "Position": 233.950623, + "HyperDash": false }, { "StartTime": 9687, - "Position": 265.3786 + "Position": 265.3786, + "HyperDash": false }, { "StartTime": 9750, - "Position": 236.8865 + "Position": 236.8865, + "HyperDash": false }, { "StartTime": 9812, - "Position": 273.38974 + "Position": 273.38974, + "HyperDash": false }, { "StartTime": 9875, - "Position": 267.701874 + "Position": 267.701874, + "HyperDash": false }, { "StartTime": 9937, - "Position": 263.2331 + "Position": 263.2331, + "HyperDash": false }, { "StartTime": 10000, - "Position": 270.339874 + "Position": 270.339874, + "HyperDash": false }, { "StartTime": 10062, - "Position": 291.9349 + "Position": 291.9349, + "HyperDash": false }, { "StartTime": 10125, - "Position": 294.2969 + "Position": 294.2969, + "HyperDash": false }, { "StartTime": 10187, - "Position": 307.834137 + "Position": 307.834137, + "HyperDash": false }, { "StartTime": 10250, - "Position": 310.6449 + "Position": 310.6449, + "HyperDash": false }, { "StartTime": 10312, - "Position": 344.746338 + "Position": 344.746338, + "HyperDash": false }, { "StartTime": 10375, - "Position": 349.21875 + "Position": 349.21875, + "HyperDash": false }, { "StartTime": 10437, - "Position": 373.943 + "Position": 373.943, + "HyperDash": false }, { "StartTime": 10500, - "Position": 401.0588 + "Position": 401.0588, + "HyperDash": false }, { "StartTime": 10558, - "Position": 421.21347 + "Position": 421.21347, + "HyperDash": false }, { "StartTime": 10616, - "Position": 431.6034 + "Position": 431.6034, + "HyperDash": false }, { "StartTime": 10674, - "Position": 433.835754 + "Position": 433.835754, + "HyperDash": false }, { "StartTime": 10732, - "Position": 452.5042 + "Position": 452.5042, + "HyperDash": false }, { "StartTime": 10790, - "Position": 486.290955 + "Position": 486.290955, + "HyperDash": false }, { "StartTime": 10848, - "Position": 488.943237 + "Position": 488.943237, + "HyperDash": false }, { "StartTime": 10906, - "Position": 493.3372 + "Position": 493.3372, + "HyperDash": false }, { "StartTime": 10999, - "Position": 508.166229 + "Position": 508.166229, + "HyperDash": false } ] }, @@ -563,39 +697,48 @@ "StartTime": 11500, "Objects": [{ "StartTime": 11500, - "Position": 97 + "Position": 97, + "HyperDash": false }, { "StartTime": 11562, - "Position": 267 + "Position": 267, + "HyperDash": false }, { "StartTime": 11625, - "Position": 116 + "Position": 116, + "HyperDash": false }, { "StartTime": 11687, - "Position": 451 + "Position": 451, + "HyperDash": false }, { "StartTime": 11750, - "Position": 414 + "Position": 414, + "HyperDash": false }, { "StartTime": 11812, - "Position": 88 + "Position": 88, + "HyperDash": false }, { "StartTime": 11875, - "Position": 257 + "Position": 257, + "HyperDash": false }, { "StartTime": 11937, - "Position": 175 + "Position": 175, + "HyperDash": false }, { "StartTime": 12000, - "Position": 38 + "Position": 38, + "HyperDash": false } ] }, @@ -603,263 +746,328 @@ "StartTime": 12500, "Objects": [{ "StartTime": 12500, - "Position": 512 + "Position": 512, + "HyperDash": false }, { "StartTime": 12562, - "Position": 494.3132 + "Position": 494.3132, + "HyperDash": false }, { "StartTime": 12625, - "Position": 461.3089 + "Position": 461.3089, + "HyperDash": false }, { "StartTime": 12687, - "Position": 469.6221 + "Position": 469.6221, + "HyperDash": false }, { "StartTime": 12750, - "Position": 441.617767 + "Position": 441.617767, + "HyperDash": false }, { "StartTime": 12812, - "Position": 402.930969 + "Position": 402.930969, + "HyperDash": false }, { "StartTime": 12875, - "Position": 407.926666 + "Position": 407.926666, + "HyperDash": false }, { "StartTime": 12937, - "Position": 364.239868 + "Position": 364.239868, + "HyperDash": false }, { "StartTime": 13000, - "Position": 353.235535 + "Position": 353.235535, + "HyperDash": false }, { "StartTime": 13062, - "Position": 320.548767 + "Position": 320.548767, + "HyperDash": false }, { "StartTime": 13125, - "Position": 303.544434 + "Position": 303.544434, + "HyperDash": false }, { "StartTime": 13187, - "Position": 295.857635 + "Position": 295.857635, + "HyperDash": false }, { "StartTime": 13250, - "Position": 265.853333 + "Position": 265.853333, + "HyperDash": false }, { "StartTime": 13312, - "Position": 272.166534 + "Position": 272.166534, + "HyperDash": false }, { "StartTime": 13375, - "Position": 240.1622 + "Position": 240.1622, + "HyperDash": false }, { "StartTime": 13437, - "Position": 229.4754 + "Position": 229.4754, + "HyperDash": false }, { "StartTime": 13500, - "Position": 194.471069 + "Position": 194.471069, + "HyperDash": false }, { "StartTime": 13562, - "Position": 158.784271 + "Position": 158.784271, + "HyperDash": false }, { "StartTime": 13625, - "Position": 137.779968 + "Position": 137.779968, + "HyperDash": false }, { "StartTime": 13687, - "Position": 147.09314 + "Position": 147.09314, + "HyperDash": false }, { "StartTime": 13750, - "Position": 122.088837 + "Position": 122.088837, + "HyperDash": false }, { "StartTime": 13812, - "Position": 77.40204 + "Position": 77.40204, + "HyperDash": false }, { "StartTime": 13875, - "Position": 79.3977356 + "Position": 79.3977356, + "HyperDash": false }, { "StartTime": 13937, - "Position": 56.710907 + "Position": 56.710907, + "HyperDash": false }, { "StartTime": 14000, - "Position": 35.7066345 + "Position": 35.7066345, + "HyperDash": false }, { "StartTime": 14062, - "Position": 1.01980591 + "Position": 1.01980591, + "HyperDash": false }, { "StartTime": 14125, - "Position": 0 + "Position": 0, + "HyperDash": false }, { "StartTime": 14187, - "Position": 21.7696266 + "Position": 21.7696266, + "HyperDash": false }, { "StartTime": 14250, - "Position": 49.0119171 + "Position": 49.0119171, + "HyperDash": false }, { "StartTime": 14312, - "Position": 48.9488258 + "Position": 48.9488258, + "HyperDash": false }, { "StartTime": 14375, - "Position": 87.19112 + "Position": 87.19112, + "HyperDash": false }, { "StartTime": 14437, - "Position": 97.12803 + "Position": 97.12803, + "HyperDash": false }, { "StartTime": 14500, - "Position": 118.370323 + "Position": 118.370323, + "HyperDash": false }, { "StartTime": 14562, - "Position": 130.307236 + "Position": 130.307236, + "HyperDash": false }, { "StartTime": 14625, - "Position": 154.549515 + "Position": 154.549515, + "HyperDash": false }, { "StartTime": 14687, - "Position": 190.486435 + "Position": 190.486435, + "HyperDash": false }, { "StartTime": 14750, - "Position": 211.728714 + "Position": 211.728714, + "HyperDash": false }, { "StartTime": 14812, - "Position": 197.665634 + "Position": 197.665634, + "HyperDash": false }, { "StartTime": 14875, - "Position": 214.907928 + "Position": 214.907928, + "HyperDash": false }, { "StartTime": 14937, - "Position": 263.844849 + "Position": 263.844849, + "HyperDash": false }, { "StartTime": 15000, - "Position": 271.087128 + "Position": 271.087128, + "HyperDash": false }, { "StartTime": 15062, - "Position": 270.024017 + "Position": 270.024017, + "HyperDash": false }, { "StartTime": 15125, - "Position": 308.266327 + "Position": 308.266327, + "HyperDash": false }, { "StartTime": 15187, - "Position": 313.203247 + "Position": 313.203247, + "HyperDash": false }, { "StartTime": 15250, - "Position": 328.445526 + "Position": 328.445526, + "HyperDash": false }, { "StartTime": 15312, - "Position": 370.382446 + "Position": 370.382446, + "HyperDash": false }, { "StartTime": 15375, - "Position": 387.624725 + "Position": 387.624725, + "HyperDash": false }, { "StartTime": 15437, - "Position": 421.561646 + "Position": 421.561646, + "HyperDash": false }, { "StartTime": 15500, - "Position": 423.803925 + "Position": 423.803925, + "HyperDash": false }, { "StartTime": 15562, - "Position": 444.740845 + "Position": 444.740845, + "HyperDash": false }, { "StartTime": 15625, - "Position": 469.983124 + "Position": 469.983124, + "HyperDash": false }, { "StartTime": 15687, - "Position": 473.920044 + "Position": 473.920044, + "HyperDash": false }, { "StartTime": 15750, - "Position": 501.162323 + "Position": 501.162323, + "HyperDash": false }, { "StartTime": 15812, - "Position": 488.784332 + "Position": 488.784332, + "HyperDash": false }, { "StartTime": 15875, - "Position": 466.226227 + "Position": 466.226227, + "HyperDash": false }, { "StartTime": 15937, - "Position": 445.978638 + "Position": 445.978638, + "HyperDash": false }, { "StartTime": 16000, - "Position": 446.420532 + "Position": 446.420532, + "HyperDash": false }, { "StartTime": 16058, - "Position": 428.4146 + "Position": 428.4146, + "HyperDash": false }, { "StartTime": 16116, - "Position": 420.408844 + "Position": 420.408844, + "HyperDash": false }, { "StartTime": 16174, - "Position": 374.402924 + "Position": 374.402924, + "HyperDash": false }, { "StartTime": 16232, - "Position": 371.397156 + "Position": 371.397156, + "HyperDash": false }, { "StartTime": 16290, - "Position": 350.391235 + "Position": 350.391235, + "HyperDash": false }, { "StartTime": 16348, - "Position": 340.385468 + "Position": 340.385468, + "HyperDash": false }, { "StartTime": 16406, - "Position": 337.3797 + "Position": 337.3797, + "HyperDash": false }, { "StartTime": 16500, - "Position": 291.1977 + "Position": 291.1977, + "HyperDash": false } ] }, @@ -867,71 +1075,88 @@ "StartTime": 17000, "Objects": [{ "StartTime": 17000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 17062, - "Position": 247.16 + "Position": 247.16, + "HyperDash": false }, { "StartTime": 17125, - "Position": 211 + "Position": 211, + "HyperDash": false }, { "StartTime": 17187, - "Position": 183.16 + "Position": 183.16, + "HyperDash": false }, { "StartTime": 17250, - "Position": 176 + "Position": 176, + "HyperDash": false }, { "StartTime": 17312, - "Position": 204.84 + "Position": 204.84, + "HyperDash": false }, { "StartTime": 17375, - "Position": 218 + "Position": 218, + "HyperDash": false }, { "StartTime": 17437, - "Position": 231.84 + "Position": 231.84, + "HyperDash": false }, { "StartTime": 17500, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 17562, - "Position": 229.16 + "Position": 229.16, + "HyperDash": false }, { "StartTime": 17625, - "Position": 227 + "Position": 227, + "HyperDash": false }, { "StartTime": 17687, - "Position": 186.16 + "Position": 186.16, + "HyperDash": false }, { "StartTime": 17750, - "Position": 176 + "Position": 176, + "HyperDash": false }, { "StartTime": 17803, - "Position": 211.959991 + "Position": 211.959991, + "HyperDash": false }, { "StartTime": 17857, - "Position": 197.23999 + "Position": 197.23999, + "HyperDash": false }, { "StartTime": 17910, - "Position": 225.200012 + "Position": 225.200012, + "HyperDash": false }, { "StartTime": 18000, - "Position": 256 + "Position": 256, + "HyperDash": false } ] }, @@ -939,71 +1164,88 @@ "StartTime": 18500, "Objects": [{ "StartTime": 18500, - "Position": 437 + "Position": 437, + "HyperDash": false }, { "StartTime": 18559, - "Position": 289 + "Position": 289, + "HyperDash": false }, { "StartTime": 18618, - "Position": 464 + "Position": 464, + "HyperDash": false }, { "StartTime": 18678, - "Position": 36 + "Position": 36, + "HyperDash": false }, { "StartTime": 18737, - "Position": 378 + "Position": 378, + "HyperDash": false }, { "StartTime": 18796, - "Position": 297 + "Position": 297, + "HyperDash": false }, { "StartTime": 18856, - "Position": 418 + "Position": 418, + "HyperDash": false }, { "StartTime": 18915, - "Position": 329 + "Position": 329, + "HyperDash": false }, { "StartTime": 18975, - "Position": 338 + "Position": 338, + "HyperDash": false }, { "StartTime": 19034, - "Position": 394 + "Position": 394, + "HyperDash": false }, { "StartTime": 19093, - "Position": 40 + "Position": 40, + "HyperDash": false }, { "StartTime": 19153, - "Position": 13 + "Position": 13, + "HyperDash": false }, { "StartTime": 19212, - "Position": 80 + "Position": 80, + "HyperDash": false }, { "StartTime": 19271, - "Position": 138 + "Position": 138, + "HyperDash": false }, { "StartTime": 19331, - "Position": 311 + "Position": 311, + "HyperDash": false }, { "StartTime": 19390, - "Position": 216 + "Position": 216, + "HyperDash": false }, { "StartTime": 19450, - "Position": 310 + "Position": 310, + "HyperDash": false } ] }, @@ -1011,263 +1253,328 @@ "StartTime": 19875, "Objects": [{ "StartTime": 19875, - "Position": 216 + "Position": 216, + "HyperDash": false }, { "StartTime": 19937, - "Position": 228.307053 + "Position": 228.307053, + "HyperDash": false }, { "StartTime": 20000, - "Position": 214.036865 + "Position": 214.036865, + "HyperDash": false }, { "StartTime": 20062, - "Position": 224.312088 + "Position": 224.312088, + "HyperDash": false }, { "StartTime": 20125, - "Position": 253.838928 + "Position": 253.838928, + "HyperDash": false }, { "StartTime": 20187, - "Position": 259.9743 + "Position": 259.9743, + "HyperDash": false }, { "StartTime": 20250, - "Position": 299.999146 + "Position": 299.999146, + "HyperDash": false }, { "StartTime": 20312, - "Position": 289.669067 + "Position": 289.669067, + "HyperDash": false }, { "StartTime": 20375, - "Position": 317.446747 + "Position": 317.446747, + "HyperDash": false }, { "StartTime": 20437, - "Position": 344.750275 + "Position": 344.750275, + "HyperDash": false }, { "StartTime": 20500, - "Position": 328.0156 + "Position": 328.0156, + "HyperDash": false }, { "StartTime": 20562, - "Position": 331.472168 + "Position": 331.472168, + "HyperDash": false }, { "StartTime": 20625, - "Position": 302.165466 + "Position": 302.165466, + "HyperDash": false }, { "StartTime": 20687, - "Position": 303.044617 + "Position": 303.044617, + "HyperDash": false }, { "StartTime": 20750, - "Position": 306.457367 + "Position": 306.457367, + "HyperDash": false }, { "StartTime": 20812, - "Position": 265.220581 + "Position": 265.220581, + "HyperDash": false }, { "StartTime": 20875, - "Position": 270.3294 + "Position": 270.3294, + "HyperDash": false }, { "StartTime": 20937, - "Position": 257.57605 + "Position": 257.57605, + "HyperDash": false }, { "StartTime": 21000, - "Position": 247.803329 + "Position": 247.803329, + "HyperDash": false }, { "StartTime": 21062, - "Position": 225.958359 + "Position": 225.958359, + "HyperDash": false }, { "StartTime": 21125, - "Position": 201.79332 + "Position": 201.79332, + "HyperDash": false }, { "StartTime": 21187, - "Position": 170.948349 + "Position": 170.948349, + "HyperDash": false }, { "StartTime": 21250, - "Position": 146.78334 + "Position": 146.78334, + "HyperDash": false }, { "StartTime": 21312, - "Position": 149.93837 + "Position": 149.93837, + "HyperDash": false }, { "StartTime": 21375, - "Position": 119.121056 + "Position": 119.121056, + "HyperDash": false }, { "StartTime": 21437, - "Position": 133.387573 + "Position": 133.387573, + "HyperDash": false }, { "StartTime": 21500, - "Position": 117.503014 + "Position": 117.503014, + "HyperDash": false }, { "StartTime": 21562, - "Position": 103.749374 + "Position": 103.749374, + "HyperDash": false }, { "StartTime": 21625, - "Position": 127.165535 + "Position": 127.165535, + "HyperDash": false }, { "StartTime": 21687, - "Position": 113.029991 + "Position": 113.029991, + "HyperDash": false }, { "StartTime": 21750, - "Position": 101.547928 + "Position": 101.547928, + "HyperDash": false }, { "StartTime": 21812, - "Position": 133.856232 + "Position": 133.856232, + "HyperDash": false }, { "StartTime": 21875, - "Position": 124.28746 + "Position": 124.28746, + "HyperDash": false }, { "StartTime": 21937, - "Position": 121.754929 + "Position": 121.754929, + "HyperDash": false }, { "StartTime": 22000, - "Position": 155.528732 + "Position": 155.528732, + "HyperDash": false }, { "StartTime": 22062, - "Position": 142.1691 + "Position": 142.1691, + "HyperDash": false }, { "StartTime": 22125, - "Position": 186.802155 + "Position": 186.802155, + "HyperDash": false }, { "StartTime": 22187, - "Position": 198.6452 + "Position": 198.6452, + "HyperDash": false }, { "StartTime": 22250, - "Position": 191.892181 + "Position": 191.892181, + "HyperDash": false }, { "StartTime": 22312, - "Position": 232.713028 + "Position": 232.713028, + "HyperDash": false }, { "StartTime": 22375, - "Position": 240.4715 + "Position": 240.4715, + "HyperDash": false }, { "StartTime": 22437, - "Position": 278.3719 + "Position": 278.3719, + "HyperDash": false }, { "StartTime": 22500, - "Position": 288.907257 + "Position": 288.907257, + "HyperDash": false }, { "StartTime": 22562, - "Position": 297.353119 + "Position": 297.353119, + "HyperDash": false }, { "StartTime": 22625, - "Position": 301.273376 + "Position": 301.273376, + "HyperDash": false }, { "StartTime": 22687, - "Position": 339.98288 + "Position": 339.98288, + "HyperDash": false }, { "StartTime": 22750, - "Position": 353.078552 + "Position": 353.078552, + "HyperDash": false }, { "StartTime": 22812, - "Position": 363.8958 + "Position": 363.8958, + "HyperDash": false }, { "StartTime": 22875, - "Position": 398.054047 + "Position": 398.054047, + "HyperDash": false }, { "StartTime": 22937, - "Position": 419.739441 + "Position": 419.739441, + "HyperDash": false }, { "StartTime": 23000, - "Position": 435.178467 + "Position": 435.178467, + "HyperDash": false }, { "StartTime": 23062, - "Position": 420.8687 + "Position": 420.8687, + "HyperDash": false }, { "StartTime": 23125, - "Position": 448.069977 + "Position": 448.069977, + "HyperDash": false }, { "StartTime": 23187, - "Position": 425.688477 + "Position": 425.688477, + "HyperDash": false }, { "StartTime": 23250, - "Position": 426.9612 + "Position": 426.9612, + "HyperDash": false }, { "StartTime": 23312, - "Position": 454.92807 + "Position": 454.92807, + "HyperDash": false }, { "StartTime": 23375, - "Position": 439.749878 + "Position": 439.749878, + "HyperDash": false }, { "StartTime": 23433, - "Position": 440.644684 + "Position": 440.644684, + "HyperDash": false }, { "StartTime": 23491, - "Position": 445.7359 + "Position": 445.7359, + "HyperDash": false }, { "StartTime": 23549, - "Position": 432.0944 + "Position": 432.0944, + "HyperDash": false }, { "StartTime": 23607, - "Position": 415.796173 + "Position": 415.796173, + "HyperDash": false }, { "StartTime": 23665, - "Position": 407.897461 + "Position": 407.897461, + "HyperDash": false }, { "StartTime": 23723, - "Position": 409.462555 + "Position": 409.462555, + "HyperDash": false }, { "StartTime": 23781, - "Position": 406.53775 + "Position": 406.53775, + "HyperDash": false }, { "StartTime": 23874, - "Position": 408.720825 + "Position": 408.720825, + "HyperDash": false } ] } diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash-expected-conversion.json new file mode 100644 index 0000000000..b2e9431f13 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash-expected-conversion.json @@ -0,0 +1,19 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "Position": 0, + "HyperDash": true + }] + }, + { + "StartTime": 450, + "Objects": [{ + "StartTime": 450, + "Position": 512, + "HyperDash": false + }] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash.osu b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash.osu new file mode 100644 index 0000000000..db07f8c30e --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash.osu @@ -0,0 +1,21 @@ +osu file format v14 + +[General] +StackLeniency: 0.7 +Mode: 2 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:9.6 +ApproachRate:9.6 +SliderMultiplier:1.9 +SliderTickRate:1 + +[TimingPoints] +2169,266.666666666667,4,2,1,70,1,0 + + +[HitObjects] +0,192,369,1,0,0:0:0:0: +512,192,450,1,0,0:0:0:0: diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json index 83f9e30800..081b574c5b 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json @@ -3,147 +3,183 @@ "StartTime": 369, "Objects": [{ "StartTime": 369, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 450, - "Position": 216.539276 + "Position": 216.539276, + "HyperDash": false }, { "StartTime": 532, - "Position": 256.5667 + "Position": 256.5667, + "HyperDash": false }, { "StartTime": 614, - "Position": 296.594116 + "Position": 296.594116, + "HyperDash": false }, { "StartTime": 696, - "Position": 336.621521 + "Position": 336.621521, + "HyperDash": false }, { "StartTime": 778, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false }, { "StartTime": 860, - "Position": 337.318878 + "Position": 337.318878, + "HyperDash": false }, { "StartTime": 942, - "Position": 297.291443 + "Position": 297.291443, + "HyperDash": false }, { "StartTime": 1024, - "Position": 257.264038 + "Position": 257.264038, + "HyperDash": false }, { "StartTime": 1106, - "Position": 217.2366 + "Position": 217.2366, + "HyperDash": false }, { "StartTime": 1188, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 1270, - "Position": 216.818192 + "Position": 216.818192, + "HyperDash": false }, { "StartTime": 1352, - "Position": 256.8456 + "Position": 256.8456, + "HyperDash": false }, { "StartTime": 1434, - "Position": 296.873047 + "Position": 296.873047, + "HyperDash": false }, { "StartTime": 1516, - "Position": 336.900452 + "Position": 336.900452, + "HyperDash": false }, { "StartTime": 1598, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false }, { "StartTime": 1680, - "Position": 337.039948 + "Position": 337.039948, + "HyperDash": false }, { "StartTime": 1762, - "Position": 297.0125 + "Position": 297.0125, + "HyperDash": false }, { "StartTime": 1844, - "Position": 256.9851 + "Position": 256.9851, + "HyperDash": false }, { "StartTime": 1926, - "Position": 216.957672 + "Position": 216.957672, + "HyperDash": false }, { "StartTime": 2008, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 2090, - "Position": 217.097137 + "Position": 217.097137, + "HyperDash": false }, { "StartTime": 2172, - "Position": 257.124573 + "Position": 257.124573, + "HyperDash": false }, { "StartTime": 2254, - "Position": 297.152 + "Position": 297.152, + "HyperDash": false }, { "StartTime": 2336, - "Position": 337.179443 + "Position": 337.179443, + "HyperDash": false }, { "StartTime": 2418, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false }, { "StartTime": 2500, - "Position": 336.760956 + "Position": 336.760956, + "HyperDash": false }, { "StartTime": 2582, - "Position": 296.733643 + "Position": 296.733643, + "HyperDash": false }, { "StartTime": 2664, - "Position": 256.7062 + "Position": 256.7062, + "HyperDash": false }, { "StartTime": 2746, - "Position": 216.678772 + "Position": 216.678772, + "HyperDash": false }, { "StartTime": 2828, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 2909, - "Position": 216.887909 + "Position": 216.887909, + "HyperDash": false }, { "StartTime": 2991, - "Position": 256.915344 + "Position": 256.915344, + "HyperDash": false }, { "StartTime": 3073, - "Position": 296.942749 + "Position": 296.942749, + "HyperDash": false }, { "StartTime": 3155, - "Position": 336.970184 + "Position": 336.970184, + "HyperDash": false }, { "StartTime": 3237, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false } ] }] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json index 7333b600fb..01f474c149 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json @@ -3,71 +3,88 @@ "StartTime": 369, "Objects": [{ "StartTime": 369, - "Position": 65 + "Position": 65, + "HyperDash": false }, { "StartTime": 450, - "Position": 482 + "Position": 482, + "HyperDash": false }, { "StartTime": 532, - "Position": 164 + "Position": 164, + "HyperDash": false }, { "StartTime": 614, - "Position": 315 + "Position": 315, + "HyperDash": false }, { "StartTime": 696, - "Position": 145 + "Position": 145, + "HyperDash": false }, { "StartTime": 778, - "Position": 159 + "Position": 159, + "HyperDash": false }, { "StartTime": 860, - "Position": 310 + "Position": 310, + "HyperDash": false }, { "StartTime": 942, - "Position": 441 + "Position": 441, + "HyperDash": false }, { "StartTime": 1024, - "Position": 428 + "Position": 428, + "HyperDash": false }, { "StartTime": 1106, - "Position": 243 + "Position": 243, + "HyperDash": false }, { "StartTime": 1188, - "Position": 422 + "Position": 422, + "HyperDash": false }, { "StartTime": 1270, - "Position": 481 + "Position": 481, + "HyperDash": false }, { "StartTime": 1352, - "Position": 104 + "Position": 104, + "HyperDash": false }, { "StartTime": 1434, - "Position": 473 + "Position": 473, + "HyperDash": false }, { "StartTime": 1516, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 1598, - "Position": 360 + "Position": 360, + "HyperDash": false }, { "StartTime": 1680, - "Position": 123 + "Position": 123, + "HyperDash": false } ] }] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json index bbc16ab912..8eaaf3bb90 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json @@ -3,231 +3,264 @@ "StartTime": 369, "Objects": [{ "StartTime": 369, - "Position": 258 + "Position": 258, + "HyperDash": false }] }, { "StartTime": 450, "Objects": [{ "StartTime": 450, - "Position": 254 + "Position": 254, + "HyperDash": false }] }, { "StartTime": 532, "Objects": [{ "StartTime": 532, - "Position": 241 + "Position": 241, + "HyperDash": false }] }, { "StartTime": 614, "Objects": [{ "StartTime": 614, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 696, "Objects": [{ "StartTime": 696, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 778, "Objects": [{ "StartTime": 778, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 860, "Objects": [{ "StartTime": 860, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 942, "Objects": [{ "StartTime": 942, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1024, "Objects": [{ "StartTime": 1024, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1106, "Objects": [{ "StartTime": 1106, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1188, "Objects": [{ "StartTime": 1188, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1270, "Objects": [{ "StartTime": 1270, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1352, "Objects": [{ "StartTime": 1352, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1434, "Objects": [{ "StartTime": 1434, - "Position": 258 + "Position": 258, + "HyperDash": false }] }, { "StartTime": 1516, "Objects": [{ "StartTime": 1516, - "Position": 253 + "Position": 253, + "HyperDash": false }] }, { "StartTime": 1598, "Objects": [{ "StartTime": 1598, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1680, "Objects": [{ "StartTime": 1680, - "Position": 260 + "Position": 260, + "HyperDash": false }] }, { "StartTime": 1762, "Objects": [{ "StartTime": 1762, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1844, "Objects": [{ "StartTime": 1844, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1926, "Objects": [{ "StartTime": 1926, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 2008, "Objects": [{ "StartTime": 2008, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2090, "Objects": [{ "StartTime": 2090, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2172, "Objects": [{ "StartTime": 2172, - "Position": 243 + "Position": 243, + "HyperDash": false }] }, { "StartTime": 2254, "Objects": [{ "StartTime": 2254, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 2336, "Objects": [{ "StartTime": 2336, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 2418, "Objects": [{ "StartTime": 2418, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2500, "Objects": [{ "StartTime": 2500, - "Position": 258 + "Position": 258, + "HyperDash": false }] }, { "StartTime": 2582, "Objects": [{ "StartTime": 2582, - "Position": 256 + "Position": 256, + "HyperDash": false }] }, { "StartTime": 2664, "Objects": [{ "StartTime": 2664, - "Position": 242 + "Position": 242, + "HyperDash": false }] }, { "StartTime": 2746, "Objects": [{ "StartTime": 2746, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2828, "Objects": [{ "StartTime": 2828, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2909, "Objects": [{ "StartTime": 2909, - "Position": 271 + "Position": 271, + "HyperDash": false }] }, { "StartTime": 2991, "Objects": [{ "StartTime": 2991, - "Position": 254 + "Position": 254, + "HyperDash": false }] } ] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json index 3bde97070c..5060389ad8 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json @@ -3,14 +3,16 @@ "StartTime": 3368, "Objects": [{ "StartTime": 3368, - "Position": 374 + "Position": 374, + "HyperDash": false }] }, { "StartTime": 3501, "Objects": [{ "StartTime": 3501, - "Position": 446 + "Position": 446, + "HyperDash": false }] } ] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json index 58c52b6867..2378ba5511 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json @@ -1 +1,71 @@ -{"Mappings":[{"StartTime":19184.0,"Objects":[{"StartTime":19184.0,"Position":320.0},{"StartTime":19263.0,"Position":311.730255},{"StartTime":19343.0,"Position":324.6205},{"StartTime":19423.0,"Position":343.0907},{"StartTime":19503.0,"Position":372.2917},{"StartTime":19582.0,"Position":385.194733},{"StartTime":19662.0,"Position":379.0426},{"StartTime":19742.0,"Position":385.1066},{"StartTime":19822.0,"Position":391.624664},{"StartTime":19919.0,"Position":386.27832},{"StartTime":20016.0,"Position":380.117035},{"StartTime":20113.0,"Position":381.664154},{"StartTime":20247.0,"Position":370.872864}]}]} \ No newline at end of file +{ + "Mappings": [{ + "StartTime": 19184, + "Objects": [{ + "StartTime": 19184, + "Position": 320, + "HyperDash": false + }, + { + "StartTime": 19263, + "Position": 311.730255, + "HyperDash": false + }, + { + "StartTime": 19343, + "Position": 324.6205, + "HyperDash": false + }, + { + "StartTime": 19423, + "Position": 343.0907, + "HyperDash": false + }, + { + "StartTime": 19503, + "Position": 372.2917, + "HyperDash": false + }, + { + "StartTime": 19582, + "Position": 385.194733, + "HyperDash": false + }, + { + "StartTime": 19662, + "Position": 379.0426, + "HyperDash": false + }, + { + "StartTime": 19742, + "Position": 385.1066, + "HyperDash": false + }, + { + "StartTime": 19822, + "Position": 391.624664, + "HyperDash": false + }, + { + "StartTime": 19919, + "Position": 386.27832, + "HyperDash": false + }, + { + "StartTime": 20016, + "Position": 380.117035, + "HyperDash": false + }, + { + "StartTime": 20113, + "Position": 381.664154, + "HyperDash": false + }, + { + "StartTime": 20247, + "Position": 370.872864, + "HyperDash": false + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json index dd81947f1c..abd5b2afd1 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json @@ -3,18 +3,21 @@ "StartTime": 2589, "Objects": [{ "StartTime": 2589, - "Position": 256 + "Position": 256, + "HyperDash": false }] }, { "StartTime": 2915, "Objects": [{ "StartTime": 2915, - "Position": 65 + "Position": 65, + "HyperDash": false }, { "StartTime": 2916, - "Position": 482 + "Position": 482, + "HyperDash": false } ] }, @@ -22,11 +25,13 @@ "StartTime": 3078, "Objects": [{ "StartTime": 3078, - "Position": 164 + "Position": 164, + "HyperDash": false }, { "StartTime": 3079, - "Position": 315 + "Position": 315, + "HyperDash": false } ] }, @@ -34,11 +39,13 @@ "StartTime": 3241, "Objects": [{ "StartTime": 3241, - "Position": 145 + "Position": 145, + "HyperDash": false }, { "StartTime": 3242, - "Position": 159 + "Position": 159, + "HyperDash": false } ] }, @@ -46,11 +53,13 @@ "StartTime": 3404, "Objects": [{ "StartTime": 3404, - "Position": 310 + "Position": 310, + "HyperDash": false }, { "StartTime": 3405, - "Position": 441 + "Position": 441, + "HyperDash": false } ] }, @@ -58,7 +67,8 @@ "StartTime": 5197, "Objects": [{ "StartTime": 5197, - "Position": 256 + "Position": 256, + "HyperDash": false }] } ] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json index b69b1ae056..8a7847e065 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json @@ -3,71 +3,88 @@ "StartTime": 18500, "Objects": [{ "StartTime": 18500, - "Position": 65 + "Position": 65, + "HyperDash": false }, { "StartTime": 18559, - "Position": 482 + "Position": 482, + "HyperDash": false }, { "StartTime": 18618, - "Position": 164 + "Position": 164, + "HyperDash": false }, { "StartTime": 18678, - "Position": 315 + "Position": 315, + "HyperDash": false }, { "StartTime": 18737, - "Position": 145 + "Position": 145, + "HyperDash": false }, { "StartTime": 18796, - "Position": 159 + "Position": 159, + "HyperDash": false }, { "StartTime": 18856, - "Position": 310 + "Position": 310, + "HyperDash": false }, { "StartTime": 18915, - "Position": 441 + "Position": 441, + "HyperDash": false }, { "StartTime": 18975, - "Position": 428 + "Position": 428, + "HyperDash": false }, { "StartTime": 19034, - "Position": 243 + "Position": 243, + "HyperDash": false }, { "StartTime": 19093, - "Position": 422 + "Position": 422, + "HyperDash": false }, { "StartTime": 19153, - "Position": 481 + "Position": 481, + "HyperDash": false }, { "StartTime": 19212, - "Position": 104 + "Position": 104, + "HyperDash": false }, { "StartTime": 19271, - "Position": 473 + "Position": 473, + "HyperDash": false }, { "StartTime": 19331, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 19390, - "Position": 360 + "Position": 360, + "HyperDash": false }, { "StartTime": 19450, - "Position": 123 + "Position": 123, + "HyperDash": false } ] }] diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs index e1fad564a3..f6b3c3d665 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs @@ -90,9 +90,9 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) .FadeOut(duration * 2); - const float angle_variangle = 15; // should be less than 45 - directionalGlow1.Rotation = StatelessRNG.NextSingle(-angle_variangle, angle_variangle, randomSeed, 4); - directionalGlow2.Rotation = StatelessRNG.NextSingle(-angle_variangle, angle_variangle, randomSeed, 5); + const float angle_variance = 15; // should be less than 45 + directionalGlow1.Rotation = StatelessRNG.NextSingle(-angle_variance, angle_variance, randomSeed, 4); + directionalGlow2.Rotation = StatelessRNG.NextSingle(-angle_variance, angle_variance, randomSeed, 5); this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out); } diff --git a/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs index 0a3f05ae54..518071fd49 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs @@ -2,13 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using Android.App; -using Android.Content.PM; using osu.Framework.Android; using osu.Game.Tests; namespace osu.Game.Rulesets.Mania.Tests.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.SensorLandscape, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)] + [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] public class MainActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuTestBrowser(); diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist index 8780204d5b..09ed2dd007 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs index d3afbc63eb..5300747633 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.UI; using osu.Game.Tests.Visual; @@ -24,9 +25,9 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor } [BackgroundDependencyLoader] - private void load(RulesetConfigCache configCache) + private void load() { - var config = (ManiaRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + var config = (ManiaRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); config.BindWith(ManiaRulesetSetting.ScrollDirection, direction); } } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs index 24d2a786a0..a30e09cd29 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Database; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Screens.Edit; @@ -55,13 +56,13 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Test] public void TestDefaultSkin() { - AddStep("set default skin", () => skins.CurrentSkinInfo.Value = SkinInfo.Default); + AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLiveUnmanaged()); } [Test] public void TestLegacySkin() { - AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.Info); + AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.CreateInfo().ToLiveUnmanaged()); } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs index 948f088b4e..837474ad9e 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs @@ -14,7 +14,6 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] - [Timeout(10000)] public class ManiaBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs index 004793e1e5..9e6e0a7776 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs @@ -24,13 +24,13 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning public TestSceneHitExplosion() { - int runcount = 0; + int runCount = 0; AddRepeatStep("explode", () => { - runcount++; + runCount++; - if (runcount % 15 > 12) + if (runCount % 15 > 12) return; int poolIndex = 0; @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { c.Add(hitExplosionPools[poolIndex].Get(e => { - e.Apply(new JudgementResult(new HitObject(), runcount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement())); + e.Apply(new JudgementResult(new HitObject(), runCount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement())); e.Anchor = Anchor.Centre; e.Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs index 449a6ff23d..4c688520ef 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs @@ -4,7 +4,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -14,6 +13,7 @@ using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -24,9 +24,6 @@ namespace osu.Game.Rulesets.Mania.Tests [TestFixture] public class TestSceneTimingBasedNoteColouring : OsuTestScene { - [Resolved] - private RulesetConfigCache configCache { get; set; } - private Bindable configTimingBasedNoteColouring; private ManualClock clock; @@ -48,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.Tests }); AddStep("retrieve config bindable", () => { - var config = (ManiaRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + var config = (ManiaRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); configTimingBasedNoteColouring = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring); }); } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index 93a9ce3dbd..0058f6f884 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps public override IEnumerable GetStatistics() { int notes = HitObjects.Count(s => s is Note); - int holdnotes = HitObjects.Count(s => s is HoldNote); + int holdNotes = HitObjects.Count(s => s is HoldNote); return new[] { @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps { Name = @"Hold Note Count", CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), - Content = holdnotes.ToString(), + Content = holdNotes.ToString(), }, }; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index bfdef893e9..979a04ddf8 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty yield return v; // Todo: osu!mania doesn't output MaxCombo attribute for some reason. - yield return (ATTRIB_ID_STRAIN, StarRating); + yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); yield return (ATTRIB_ID_SCORE_MULTIPLIER, ScoreMultiplier); } @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty { base.FromDatabaseAttributes(values); - StarRating = values[ATTRIB_ID_STRAIN]; + StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; ScoreMultiplier = values[ATTRIB_ID_SCORE_MULTIPLIER]; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs new file mode 100644 index 0000000000..da9634ba47 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.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 Newtonsoft.Json; +using osu.Game.Rulesets.Difficulty; + +namespace osu.Game.Rulesets.Mania.Difficulty +{ + public class ManiaPerformanceAttributes : PerformanceAttributes + { + [JsonProperty("difficulty")] + public double Difficulty { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty("scaled_score")] + public double ScaledScore { get; set; } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index b04ff3548f..8a8c41bb8a 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty { } - public override double Calculate(Dictionary categoryDifficulty = null) + public override PerformanceAttributes Calculate() { mods = Score.Mods; scaledScore = Score.TotalScore; @@ -61,48 +61,46 @@ namespace osu.Game.Rulesets.Mania.Difficulty if (mods.Any(m => m is ModEasy)) multiplier *= 0.5; - double strainValue = computeStrainValue(); - double accValue = computeAccuracyValue(strainValue); + double difficultyValue = computeDifficultyValue(); + double accValue = computeAccuracyValue(difficultyValue); double totalValue = Math.Pow( - Math.Pow(strainValue, 1.1) + + Math.Pow(difficultyValue, 1.1) + Math.Pow(accValue, 1.1), 1.0 / 1.1 ) * multiplier; - if (categoryDifficulty != null) + return new ManiaPerformanceAttributes { - categoryDifficulty["Strain"] = strainValue; - categoryDifficulty["Accuracy"] = accValue; - } - - return totalValue; + Difficulty = difficultyValue, + Accuracy = accValue, + ScaledScore = scaledScore, + Total = totalValue + }; } - private double computeStrainValue() + private double computeDifficultyValue() { - // Obtain strain difficulty - double strainValue = Math.Pow(5 * Math.Max(1, Attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0; + double difficultyValue = Math.Pow(5 * Math.Max(1, Attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0; - // Longer maps are worth more - strainValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0); + difficultyValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0); if (scaledScore <= 500000) - strainValue = 0; + difficultyValue = 0; else if (scaledScore <= 600000) - strainValue *= (scaledScore - 500000) / 100000 * 0.3; + difficultyValue *= (scaledScore - 500000) / 100000 * 0.3; else if (scaledScore <= 700000) - strainValue *= 0.3 + (scaledScore - 600000) / 100000 * 0.25; + difficultyValue *= 0.3 + (scaledScore - 600000) / 100000 * 0.25; else if (scaledScore <= 800000) - strainValue *= 0.55 + (scaledScore - 700000) / 100000 * 0.20; + difficultyValue *= 0.55 + (scaledScore - 700000) / 100000 * 0.20; else if (scaledScore <= 900000) - strainValue *= 0.75 + (scaledScore - 800000) / 100000 * 0.15; + difficultyValue *= 0.75 + (scaledScore - 800000) / 100000 * 0.15; else - strainValue *= 0.90 + (scaledScore - 900000) / 100000 * 0.1; + difficultyValue *= 0.90 + (scaledScore - 900000) / 100000 * 0.1; - return strainValue; + return difficultyValue; } - private double computeAccuracyValue(double strainValue) + private double computeAccuracyValue(double difficultyValue) { if (Attributes.GreatHitWindow <= 0) return 0; @@ -110,12 +108,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution double accuracyValue = Math.Max(0.0, 0.2 - (Attributes.GreatHitWindow - 34) * 0.006667) - * strainValue + * difficultyValue * Math.Pow(Math.Max(0.0, scaledScore - 960000) / 40000, 1.1); - // Bonus for many hitcircles - it's harder to keep good accuracy up for longer - // accuracyValue *= Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); - return accuracyValue; } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index 5259fcbd5f..35889aea0c 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.UI.Scrolling; using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints @@ -28,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints } [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) + private void load() { InternalChildren = new Drawable[] { diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs index 9d1f5429a1..1aa20f4737 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -9,7 +9,6 @@ using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.UI; @@ -46,12 +45,6 @@ namespace osu.Game.Rulesets.Mania.Edit [Resolved] private EditorBeatmap beatmap { get; set; } - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } - - [Resolved] - private Bindable working { get; set; } - [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index dc858fb54f..9fe1eb7932 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -7,16 +7,12 @@ using osu.Framework.Allocation; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { public class ManiaSelectionHandler : EditorSelectionHandler { - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } - [Resolved] private HitObjectComposer composer { get; set; } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index b0e7545d3e..6fc7dc018b 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -49,7 +49,7 @@ namespace osu.Game.Rulesets.Mania public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); - public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.5); + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new ManiaHealthProcessor(drainStartTime, 0.5); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs new file mode 100644 index 0000000000..57c2ba9c6d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.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 osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Scoring +{ + public class ManiaHealthProcessor : DrainingHealthProcessor + { + /// + public ManiaHealthProcessor(double drainStartTime, double drainLenience = 0) + : base(drainStartTime, drainLenience) + { + } + + protected override HitResult GetSimulatedHitResult(Judgement judgement) + { + // Users are not expected to attain perfect judgements for all notes due to the tighter hit window. + return judgement.MaxResult == HitResult.Perfect ? HitResult.Great : judgement.MaxResult; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 0588ae57d7..431bd77402 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy /// Mapping of to their corresponding /// value. /// - private static readonly IReadOnlyDictionary hitresult_mapping + private static readonly IReadOnlyDictionary hit_result_mapping = new Dictionary { { HitResult.Perfect, LegacyManiaSkinConfigurationLookups.Hit300g }, @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy /// Mapping of to their corresponding /// default filenames. /// - private static readonly IReadOnlyDictionary default_hitresult_skin_filenames + private static readonly IReadOnlyDictionary default_hit_result_skin_filenames = new Dictionary { { HitResult.Perfect, "mania-hit300g" }, @@ -126,11 +126,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private Drawable getResult(HitResult result) { - if (!hitresult_mapping.ContainsKey(result)) + if (!hit_result_mapping.ContainsKey(result)) return null; - string filename = this.GetManiaSkinConfig(hitresult_mapping[result])?.Value - ?? default_hitresult_skin_filenames[result]; + string filename = this.GetManiaSkinConfig(hit_result_mapping[result])?.Value + ?? default_hit_result_skin_filenames[result]; var animation = this.GetAnimation(filename, true, true); return animation == null ? null : new LegacyManiaJudgementPiece(result, animation); diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs index 69b81d6d5c..562d7b04c4 100644 --- a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Mania.UI [BackgroundDependencyLoader] private void load(IScrollingInfo scrollingInfo) { - const float angle_variangle = 15; // should be less than 45 + const float angle_variance = 15; // should be less than 45 const float roundness = 80; const float initial_height = 10; @@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Mania.UI Masking = true, Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, - Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + Rotation = RNG.NextSingle(-angle_variance, angle_variance), EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, @@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Mania.UI Masking = true, Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, - Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + Rotation = RNG.NextSingle(-angle_variance, angle_variance), EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 8830c440c0..4cd6624ac6 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Mania.UI public override bool Remove(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Remove(h); - public void Add(BarLine barline) => stages.ForEach(s => s.Add(barline)); + public void Add(BarLine barLine) => stages.ForEach(s => s.Add(barLine)); /// /// Retrieves a column from a screen-space position. diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs index 90d3c6c4c7..9f4963b022 100644 --- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Mania.UI public JudgementResult Result { get; private set; } - [Resolved] - private Column column { get; set; } - private SkinnableDrawable skinnableExplosion; public PoolableHitExplosion() diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index 8c703e7a8a..94910bb410 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -147,7 +147,7 @@ namespace osu.Game.Rulesets.Mania.UI public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h); - public void Add(BarLine barline) => base.Add(new DrawableBarLine(barline)); + public void Add(BarLine barLine) => base.Add(new DrawableBarLine(barLine)); internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result) { diff --git a/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs index e6c508d99e..46c60f06a5 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs @@ -2,13 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using Android.App; -using Android.Content.PM; using osu.Framework.Android; using osu.Game.Tests; namespace osu.Game.Rulesets.Osu.Tests.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.SensorLandscape, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)] + [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] public class MainActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuTestBrowser(); diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist index f79215cf54..dd032ef1c1 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs index 6bfe7f892b..e7bcd2cadc 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs @@ -1,7 +1,10 @@ // 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 Humanizer; using NUnit.Framework; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -47,6 +50,126 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddBlueprint(new TestSliderBlueprint(slider), drawableObject); }); + [Test] + public void TestSelection() + { + moveMouseToControlPoint(0); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + assertSelectionCount(1); + assertSelected(0); + + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + assertSelectionCount(1); + assertSelected(0); + + moveMouseToControlPoint(3); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + assertSelectionCount(1); + assertSelected(3); + + AddStep("press control", () => InputManager.PressKey(Key.ControlLeft)); + moveMouseToControlPoint(2); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + assertSelectionCount(2); + assertSelected(2); + assertSelected(3); + + moveMouseToControlPoint(0); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + assertSelectionCount(3); + assertSelected(0); + assertSelected(2); + assertSelected(3); + + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + assertSelectionCount(3); + assertSelected(0); + assertSelected(2); + assertSelected(3); + + AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft)); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + assertSelectionCount(1); + assertSelected(0); + + moveMouseToRelativePosition(new Vector2(350, 0)); + AddStep("ctrl+click to create new point", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressButton(MouseButton.Left); + }); + assertSelectionCount(1); + assertSelected(3); + + AddStep("release ctrl+click", () => + { + InputManager.ReleaseButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + assertSelectionCount(1); + assertSelected(3); + } + + [Test] + public void TestNewControlPointCreation() + { + moveMouseToRelativePosition(new Vector2(350, 0)); + AddStep("ctrl+click to create new point", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressButton(MouseButton.Left); + }); + AddAssert("slider has 6 control points", () => slider.Path.ControlPoints.Count == 6); + AddStep("release ctrl+click", () => + { + InputManager.ReleaseButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + // ensure that the next drag doesn't attempt to move the placement that just finished. + moveMouseToRelativePosition(new Vector2(0, 50)); + AddStep("press left mouse", () => InputManager.PressButton(MouseButton.Left)); + moveMouseToRelativePosition(new Vector2(0, 100)); + AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + assertControlPointPosition(3, new Vector2(350, 0)); + + moveMouseToRelativePosition(new Vector2(400, 75)); + AddStep("ctrl+click to create new point", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressButton(MouseButton.Left); + }); + AddAssert("slider has 7 control points", () => slider.Path.ControlPoints.Count == 7); + moveMouseToRelativePosition(new Vector2(350, 75)); + AddStep("release ctrl+click", () => + { + InputManager.ReleaseButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + assertControlPointPosition(5, new Vector2(350, 75)); + + // ensure that the next drag doesn't attempt to move the placement that just finished. + moveMouseToRelativePosition(new Vector2(0, 50)); + AddStep("press left mouse", () => InputManager.PressButton(MouseButton.Left)); + moveMouseToRelativePosition(new Vector2(0, 100)); + AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + assertControlPointPosition(5, new Vector2(350, 75)); + } + + private void assertSelectionCount(int count) => + AddAssert($"{count} control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == count); + + private void assertSelected(int index) => + AddAssert($"{(index + 1).ToOrdinalWords()} control point piece selected", + () => this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[index]).IsSelected.Value); + + private void moveMouseToRelativePosition(Vector2 relativePosition) => + AddStep($"move mouse to {relativePosition}", () => + { + Vector2 position = slider.Position + relativePosition; + InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); + }); + [Test] public void TestDragControlPoint() { @@ -60,6 +183,83 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointType(0, PathType.PerfectCurve); } + [Test] + public void TestDragMultipleControlPoints() + { + moveMouseToControlPoint(2); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + AddStep("hold control", () => InputManager.PressKey(Key.LControl)); + + moveMouseToControlPoint(3); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + moveMouseToControlPoint(4); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + moveMouseToControlPoint(2); + AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); + + AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + + addMovementStep(new Vector2(450, 50)); + AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + + assertControlPointPosition(2, new Vector2(450, 50)); + assertControlPointType(2, PathType.PerfectCurve); + + assertControlPointPosition(3, new Vector2(550, 50)); + + assertControlPointPosition(4, new Vector2(550, 200)); + + AddStep("release control", () => InputManager.ReleaseKey(Key.LControl)); + } + + [Test] + public void TestDragMultipleControlPointsIncludingHead() + { + moveMouseToControlPoint(0); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + AddStep("hold control", () => InputManager.PressKey(Key.LControl)); + + moveMouseToControlPoint(3); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + moveMouseToControlPoint(4); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + moveMouseToControlPoint(3); + AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); + + AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + + addMovementStep(new Vector2(550, 50)); + AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + + // note: if the head is part of the selection being moved, the entire slider is moved. + // the unselected nodes will therefore change position relative to the slider head. + + AddAssert("slider moved", () => Precision.AlmostEquals(slider.Position, new Vector2(256, 192) + new Vector2(150, 50))); + + assertControlPointPosition(0, Vector2.Zero); + assertControlPointType(0, PathType.PerfectCurve); + + assertControlPointPosition(1, new Vector2(0, 100)); + + assertControlPointPosition(2, new Vector2(150, -50)); + + assertControlPointPosition(3, new Vector2(400, 0)); + + assertControlPointPosition(4, new Vector2(400, 150)); + + AddStep("release control", () => InputManager.ReleaseKey(Key.LControl)); + } + [Test] public void TestDragControlPointAlmostLinearlyExterior() { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs new file mode 100644 index 0000000000..b43b2b1461 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs @@ -0,0 +1,225 @@ +// 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.Input.Events; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneSliderSnapping : EditorTestScene + { + private const double beat_length = 1000; + + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length }); + return new TestBeatmap(ruleset, false) + { + ControlPointInfo = controlPointInfo + }; + } + + private Slider slider; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add unsnapped slider", () => EditorBeatmap.Add(slider = new Slider + { + StartTime = 0, + Position = OsuPlayfield.BASE_SIZE / 5, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(OsuPlayfield.BASE_SIZE * 2 / 5), + new PathControlPoint(OsuPlayfield.BASE_SIZE * 3 / 5) + } + } + })); + AddStep("set beat divisor to 1/1", () => + { + var beatDivisor = (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor)); + beatDivisor.Value = 1; + }); + } + + [Test] + public void TestMovingUnsnappedSliderNodesSnaps() + { + PathControlPointPiece sliderEnd = null; + + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("select slider end", () => + { + sliderEnd = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints.Last()); + InputManager.MoveMouseTo(sliderEnd.ScreenSpaceDrawQuad.Centre); + }); + AddStep("move slider end", () => + { + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(sliderEnd.ScreenSpaceDrawQuad.Centre - new Vector2(0, 20)); + InputManager.ReleaseButton(MouseButton.Left); + }); + assertSliderSnapped(true); + } + + [Test] + public void TestAddingControlPointToUnsnappedSliderNodesSnaps() + { + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + 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); + }); + AddStep("move slider end", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + assertSliderSnapped(true); + } + + [Test] + public void TestRemovingControlPointFromUnsnappedSliderNodesSnaps() + { + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("move mouse to second control point", () => + { + var secondPiece = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[1]); + InputManager.MoveMouseTo(secondPiece); + }); + AddStep("quick delete", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.PressButton(MouseButton.Right); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + assertSliderSnapped(true); + } + + [Test] + public void TestResizingUnsnappedSliderSnaps() + { + SelectionBoxScaleHandle handle = null; + + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("move mouse to scale handle", () => + { + handle = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre); + }); + AddStep("scale slider", () => + { + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre + new Vector2(20, 20)); + InputManager.ReleaseButton(MouseButton.Left); + }); + assertSliderSnapped(true); + } + + [Test] + public void TestRotatingUnsnappedSliderDoesNotSnap() + { + SelectionBoxRotationHandle handle = null; + + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("move mouse to rotate handle", () => + { + handle = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre); + }); + AddStep("scale slider", () => + { + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre + new Vector2(0, 20)); + InputManager.ReleaseButton(MouseButton.Left); + }); + assertSliderSnapped(false); + } + + [Test] + public void TestFlippingSliderDoesNotSnap() + { + OsuSelectionHandler selectionHandler = null; + + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("flip slider horizontally", () => + { + selectionHandler = this.ChildrenOfType().Single(); + selectionHandler.OnPressed(new KeyBindingPressEvent(InputManager.CurrentState, GlobalAction.EditorFlipHorizontally)); + }); + + assertSliderSnapped(false); + + AddStep("flip slider vertically", () => + { + selectionHandler = this.ChildrenOfType().Single(); + selectionHandler.OnPressed(new KeyBindingPressEvent(InputManager.CurrentState, GlobalAction.EditorFlipVertically)); + }); + + assertSliderSnapped(false); + } + + [Test] + public void TestReversingSliderDoesNotSnap() + { + assertSliderSnapped(false); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("reverse slider", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + assertSliderSnapped(false); + } + + private void assertSliderSnapped(bool snapped) + => AddAssert($"slider is {(snapped ? "" : "not ")}snapped", () => + { + double durationInBeatLengths = slider.Duration / beat_length; + double fractionalPart = durationInBeatLengths - (int)durationInBeatLengths; + return Precision.AlmostEquals(fractionalPart, 0) == snapped; + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index 5f44e1b6b6..4c11efcc7c 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -12,7 +12,6 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - [Timeout(10000)] public class OsuBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay-0@2x.png similarity index 100% rename from osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png rename to osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay-0@2x.png diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay-1@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay-1@2x.png new file mode 100755 index 0000000000..3b5e886933 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay-1@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index e698766aac..d673b7a6ac 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("create slider", () => { - var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.Info); + var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1"; Child = new SkinProvidingContainer(tintingSkin) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs index 3252e6d912..a3aa84d0e7 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs @@ -9,6 +9,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Timing; @@ -46,9 +47,9 @@ namespace osu.Game.Rulesets.Osu.Tests => new ClockBackedTestWorkingBeatmap(this.beatmap = beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); [BackgroundDependencyLoader] - private void load(RulesetConfigCache configCache) + private void load() { - var config = (OsuRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + var config = (OsuRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); config.BindWith(OsuRulesetSetting.SnakingInSliders, snakingIn); config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 4b2e54da17..126a9b0183 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -12,14 +12,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyAttributes : DifficultyAttributes { - [JsonProperty("aim_strain")] - public double AimStrain { get; set; } + [JsonProperty("aim_difficulty")] + public double AimDifficulty { get; set; } - [JsonProperty("speed_strain")] - public double SpeedStrain { get; set; } + [JsonProperty("speed_difficulty")] + public double SpeedDifficulty { get; set; } - [JsonProperty("flashlight_rating")] - public double FlashlightRating { get; set; } + [JsonProperty("flashlight_difficulty")] + public double FlashlightDifficulty { get; set; } [JsonProperty("slider_factor")] public double SliderFactor { get; set; } @@ -43,15 +43,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty foreach (var v in base.ToDatabaseAttributes()) yield return v; - yield return (ATTRIB_ID_AIM, AimStrain); - yield return (ATTRIB_ID_SPEED, SpeedStrain); + yield return (ATTRIB_ID_AIM, AimDifficulty); + yield return (ATTRIB_ID_SPEED, SpeedDifficulty); yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty); yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); - yield return (ATTRIB_ID_STRAIN, StarRating); + yield return (ATTRIB_ID_DIFFICULTY, StarRating); if (ShouldSerializeFlashlightRating()) - yield return (ATTRIB_ID_FLASHLIGHT, FlashlightRating); + yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); } @@ -60,18 +60,25 @@ namespace osu.Game.Rulesets.Osu.Difficulty { base.FromDatabaseAttributes(values); - AimStrain = values[ATTRIB_ID_AIM]; - SpeedStrain = values[ATTRIB_ID_SPEED]; + AimDifficulty = values[ATTRIB_ID_AIM]; + SpeedDifficulty = values[ATTRIB_ID_SPEED]; OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY]; ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; - StarRating = values[ATTRIB_ID_STRAIN]; - FlashlightRating = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); + StarRating = values[ATTRIB_ID_DIFFICULTY]; + FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; } - // Used implicitly by Newtonsoft.Json to not serialize flashlight property in some cases. + #region Newtonsoft.Json implicit ShouldSerialize() methods + + // The properties in this region are used implicitly by Newtonsoft.Json to not serialise certain fields in some cases. + // They rely on being named exactly the same as the corresponding fields (casing included) and as such should NOT be renamed + // unless the fields are also renamed. + [UsedImplicitly] public bool ShouldSerializeFlashlightRating() => Mods.Any(m => m is ModFlashlight); + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index ed42f333c0..c5b1baaad1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -74,9 +74,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty { StarRating = starRating, Mods = mods, - AimStrain = aimRating, - SpeedStrain = speedRating, - FlashlightRating = flashlightRating, + AimDifficulty = aimRating, + SpeedDifficulty = speedRating, + FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, OverallDifficulty = (80 - hitWindowGreat) / 6, diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs new file mode 100644 index 0000000000..6c7760d144 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.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 Newtonsoft.Json; +using osu.Game.Rulesets.Difficulty; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + public class OsuPerformanceAttributes : PerformanceAttributes + { + [JsonProperty("aim")] + public double Aim { get; set; } + + [JsonProperty("speed")] + public double Speed { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty("flashlight")] + public double Flashlight { get; set; } + + [JsonProperty("effective_miss_count")] + public double EffectiveMissCount { get; set; } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 8d45c7a8cc..fdf646ef85 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty { } - public override double Calculate(Dictionary categoryRatings = null) + public override PerformanceAttributes Calculate() { mods = Score.Mods; accuracy = Score.Accuracy; @@ -45,11 +45,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. - // Custom multipliers for NoFail and SpunOut. if (mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); - if (mods.Any(m => m is OsuModSpunOut)) + if (mods.Any(m => m is OsuModSpunOut) && totalHits > 0) multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85); if (mods.Any(h => h is OsuModRelax)) @@ -72,42 +71,35 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(flashlightValue, 1.1), 1.0 / 1.1 ) * multiplier; - if (categoryRatings != null) + return new OsuPerformanceAttributes { - categoryRatings.Add("Aim", aimValue); - categoryRatings.Add("Speed", speedValue); - categoryRatings.Add("Accuracy", accuracyValue); - categoryRatings.Add("Flashlight", flashlightValue); - categoryRatings.Add("OD", Attributes.OverallDifficulty); - categoryRatings.Add("AR", Attributes.ApproachRate); - categoryRatings.Add("Max Combo", Attributes.MaxCombo); - } - - return totalValue; + Aim = aimValue, + Speed = speedValue, + Accuracy = accuracyValue, + Flashlight = flashlightValue, + EffectiveMissCount = effectiveMissCount, + Total = totalValue + }; } private double computeAimValue() { - double rawAim = Attributes.AimStrain; + double rawAim = Attributes.AimDifficulty; if (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; - // Longer maps are worth more. double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - aimValue *= lengthBonus; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) aimValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), effectiveMissCount); - // Combo scaling. - if (Attributes.MaxCombo > 0) - aimValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + aimValue *= getComboScalingFactor(); double approachRateFactor = 0.0; if (Attributes.ApproachRate > 10.33) @@ -136,7 +128,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty } aimValue *= accuracy; - // It is important to also consider accuracy difficulty when doing that. + // It is important to consider accuracy difficulty when scaling with accuracy. aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; return aimValue; @@ -144,9 +136,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeSpeedValue() { - double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedStrain / 0.0675) - 4.0, 3.0) / 100000.0; + double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; - // Longer maps are worth more. double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); speedValue *= lengthBonus; @@ -155,9 +146,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) speedValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - // Combo scaling. - if (Attributes.MaxCombo > 0) - speedValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + speedValue *= getComboScalingFactor(); double approachRateFactor = 0.0; if (Attributes.ApproachRate > 10.33) @@ -227,14 +216,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (!mods.Any(h => h is OsuModFlashlight)) return 0.0; - double rawFlashlight = Attributes.FlashlightRating; + double rawFlashlight = Attributes.FlashlightDifficulty; if (mods.Any(m => m is OsuModTouchDevice)) rawFlashlight = Math.Pow(rawFlashlight, 0.8); double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0; - // Add an additional bonus for HDFL. if (mods.Any(h => h is OsuModHidden)) flashlightValue *= 1.3; @@ -242,9 +230,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - // Combo scaling. - if (Attributes.MaxCombo > 0) - flashlightValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + flashlightValue *= getComboScalingFactor(); // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + @@ -276,6 +262,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty return Math.Max(countMiss, (int)Math.Floor(comboBasedMissCount)); } + private double getComboScalingFactor() => 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 d073d751d0..4df8ff0b12 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -11,49 +11,65 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { public class OsuDifficultyHitObject : DifficultyHitObject { - private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private 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 = normalized_radius * 2.4f; - private const float assumed_slider_radius = normalized_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; /// - /// Normalized distance from the end position of the previous to the start position of this . + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. /// - public double JumpDistance { get; private set; } + public readonly double StrainTime; /// - /// Minimum distance from the end position of the previous to the start position of this . + /// Normalised distance from the "lazy" end position of the previous to the start position of this . + /// + /// The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles). + /// /// - public double MovementDistance { get; private set; } + public double LazyJumpDistance { get; private set; } /// - /// Normalized distance between the start and end position of the previous . + /// Normalised shortest distance to consider for a jump between the previous and this . + /// + /// + /// This is bounded from above by , and is smaller than the former if a more natural path is able to be taken through the previous . + /// + /// + /// Suppose a linear slider - circle pattern. + ///
+ /// Following the slider lazily (see: ) will result in underestimating the true end position of the slider as being closer towards the start position. + /// As a result, overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end, + /// such that the jump is felt as only starting from the slider's true end position. + ///
+ /// Now consider a slider - circle pattern where the circle is stacked along the path inside the slider. + /// In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path. + ///
+ public double MinimumJumpDistance { get; private set; } + + /// + /// The time taken to travel through , with a minimum value of 25ms. + /// + public double MinimumJumpTime { get; private set; } + + /// + /// Normalised distance between the start and end position of this . /// public double TravelDistance { get; private set; } + /// + /// The time taken to travel through , with a minimum value of 25ms for a non-zero distance. + /// + public double TravelTime { get; private set; } + /// /// Angle the player has to take to hit this . /// Calculated as the angle between the circles (current-2, current-1, current). /// public double? Angle { get; private set; } - /// - /// Milliseconds elapsed since the end time of the previous , with a minimum of 25ms. - /// - public double MovementTime { get; private set; } - - /// - /// Milliseconds elapsed since the start time of the previous to the end time of the same previous , with a minimum of 25ms. - /// - public double TravelTime { get; private set; } - - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. - /// - public readonly double StrainTime; - private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -71,12 +87,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing private void setDistances(double clockRate) { + if (BaseObject is Slider currentSlider) + { + computeSliderCursorPosition(currentSlider); + TravelDistance = currentSlider.LazyTravelDistance; + TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); + } + // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || lastObject is Spinner) return; // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalized_radius / (float)BaseObject.Radius; + float scalingFactor = normalised_radius / (float)BaseObject.Radius; if (BaseObject.Radius < 30) { @@ -85,29 +108,40 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing } Vector2 lastCursorPosition = getEndCursorPosition(lastObject); - JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + + LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + MinimumJumpTime = StrainTime; + MinimumJumpDistance = LazyJumpDistance; if (lastObject is Slider lastSlider) { - computeSliderCursorPosition(lastSlider); - TravelDistance = lastSlider.LazyTravelDistance; - TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); - MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time); + double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); + MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time); + + // + // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. + // + // 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject. + // + // <======o==> ← slider + // | ← most natural jump path + // o ← a follow-up hitcircle + // + // In this case the most natural jump path is approximated by LazyJumpDistance. + // + // 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject. + // + // <======o==>---o + // ↑ + // most natural jump path + // + // In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject. + // + // Thus, the player is assumed to jump the minimum of these two distances in all cases. + // - // Jump distance from the slider tail to the next object, as opposed to the lazy position of JumpDistance. float tailJumpDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor; - - // For hitobjects which continue in the direction of the slider, the player will normally follow through the slider, - // such that they're not jumping from the lazy position but rather from very close to (or the end of) the slider. - // In such cases, a leniency is applied by also considering the jump distance from the tail of the slider, and taking the minimum jump distance. - // Additional distance is removed based on position of jump relative to slider follow circle radius. - // JumpDistance is the leniency distance beyond the assumed_slider_radius. tailJumpDistance is maximum_slider_radius since the full distance of radial leniency is still possible. - MovementDistance = Math.Max(0, Math.Min(JumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); - } - else - { - MovementTime = StrainTime; - MovementDistance = JumpDistance; + MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); } if (lastLastObject != null && !(lastLastObject is Spinner)) @@ -139,7 +173,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 = normalized_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++) { @@ -167,7 +201,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 = normalized_radius; + requiredMovement = normalised_radius; } if (currMovementLength > requiredMovement) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 2a8d2ce759..a6301aed6d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -44,24 +44,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills var osuLastLastObj = (OsuDifficultyHitObject)Previous[1]; // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime; + double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. if (osuLastObj.BaseObject is Slider && withSliders) { - double movementVelocity = osuCurrObj.MovementDistance / osuCurrObj.MovementTime; // calculate the movement velocity from slider end to current object - double travelVelocity = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; // calculate the slider velocity from slider head to slider end. + double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. + double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. } // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.JumpDistance / osuLastObj.StrainTime; + double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; if (osuLastLastObj.BaseObject is Slider && withSliders) { - double movementVelocity = osuLastObj.MovementDistance / osuLastObj.MovementTime; - double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; + double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; + double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); } @@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.JumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). } // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. @@ -107,8 +107,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills if (Math.Max(prevVelocity, currVelocity) != 0) { // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.JumpDistance + osuLastObj.TravelDistance) / osuLastObj.StrainTime; - currVelocity = (osuCurrObj.JumpDistance + osuCurrObj.TravelDistance) / osuCurrObj.StrainTime; + prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime; + currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; // Scale with ratio of difference compared to 0.5 * max dist. double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); @@ -119,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills // 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.JumpDistance, osuLastObj.JumpDistance) / 100)), 2); + * 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; @@ -128,10 +128,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); } - if (osuCurrObj.TravelTime != 0) + if (osuLastObj.TravelTime != 0) { // Reward sliders based on velocity. - sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; } // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 466f0556ab..03abba29ce 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { } - private double skillMultiplier => 0.15; + private double skillMultiplier => 0.07; private double strainDecayBase => 0.15; protected override double DecayWeight => 1.0; protected override int HistoryLength => 10; // Look back for 10 notes is added for the sake of flashlight calculations. @@ -40,26 +40,31 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills double result = 0.0; + OsuDifficultyHitObject lastObj = osuCurrent; + + // This is iterating backwards in time from the current object. for (int i = 0; i < Previous.Count; i++) { - var osuPrevious = (OsuDifficultyHitObject)Previous[i]; - var osuPreviousHitObject = (OsuHitObject)(osuPrevious.BaseObject); + var currentObj = (OsuDifficultyHitObject)Previous[i]; + var currentHitObject = (OsuHitObject)(currentObj.BaseObject); - if (!(osuPrevious.BaseObject is Spinner)) + if (!(currentObj.BaseObject is Spinner)) { - double jumpDistance = (osuHitObject.StackedPosition - osuPreviousHitObject.EndPosition).Length; + double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.EndPosition).Length; - cumulativeStrainTime += osuPrevious.StrainTime; + cumulativeStrainTime += lastObj.StrainTime; // We want to nerf objects that can be easily seen within the Flashlight circle radius. if (i == 0) smallDistNerf = Math.Min(1.0, jumpDistance / 75.0); // We also want to nerf stacks so that only the first object of the stack is accounted for. - double stackNerf = Math.Min(1.0, (osuPrevious.JumpDistance / scalingFactor) / 25.0); + double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); - result += Math.Pow(0.8, i) * stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime; + result += stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime; } + + lastObj = currentObj; } return Math.Pow(smallDistNerf * result, 2.0); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 24881d9c47..06d1ef7346 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -55,73 +55,75 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills bool firstDeltaSwitch = false; - for (int i = Previous.Count - 2; i > 0; i--) + int rhythmStart = 0; + + while (rhythmStart < Previous.Count - 2 && current.StartTime - Previous[rhythmStart].StartTime < history_time_max) + rhythmStart++; + + for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)Previous[i - 1]; OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)Previous[i]; OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)Previous[i + 1]; - double currHistoricalDecay = Math.Max(0, (history_time_max - (current.StartTime - currObj.StartTime))) / history_time_max; // scales note 0 to 1 from history to now + double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now - if (currHistoricalDecay != 0) + currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. + + double currDelta = currObj.StrainTime; + double prevDelta = prevObj.StrainTime; + 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)); + + windowPenalty = Math.Min(1, windowPenalty); + + double effectiveRatio = windowPenalty * currRatio; + + if (firstDeltaSwitch) { - currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. - - double currDelta = currObj.StrainTime; - double prevDelta = prevObj.StrainTime; - 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)); - - windowPenalty = Math.Min(1, windowPenalty); - - double effectiveRatio = windowPenalty * currRatio; - - if (firstDeltaSwitch) + if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) { - if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) - { - if (islandSize < 7) - islandSize++; // island is still progressing, count size. - } - else - { - if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window - effectiveRatio *= 0.125; - - if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle - effectiveRatio *= 0.25; - - if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) - effectiveRatio *= 0.25; - - if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) - effectiveRatio *= 0.50; - - if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - effectiveRatio *= 0.125; - - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; - - startRatio = effectiveRatio; - - previousIslandSize = islandSize; // log the last island size. - - if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting - firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - - islandSize = 1; - } + if (islandSize < 7) + islandSize++; // island is still progressing, count size. } - else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + else { - // Begin counting island until we change speed again. - firstDeltaSwitch = true; + if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window + effectiveRatio *= 0.125; + + if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle + effectiveRatio *= 0.25; + + if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) + effectiveRatio *= 0.25; + + if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) + effectiveRatio *= 0.50; + + if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + effectiveRatio *= 0.125; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + startRatio = effectiveRatio; + + previousIslandSize = islandSize; // log the last island size. + + if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + islandSize = 1; } } + else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + { + // Begin counting island until we change speed again. + firstDeltaSwitch = true; + startRatio = effectiveRatio; + islandSize = 1; + } } return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) @@ -154,7 +156,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills if (strainTime < min_speed_bonus) speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); - double distance = Math.Min(single_spacing_threshold, osuCurrObj.TravelDistance + osuCurrObj.JumpDistance); + double travelDistance = osuPrevObj?.TravelDistance ?? 0; + double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance); return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / strainTime; } 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 b3e0566a98..09759aa118 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -16,11 +16,9 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Screens.Edit; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -33,6 +31,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public class PathControlPointPiece : BlueprintPiece, IHasTooltip { public Action RequestSelection; + + public Action DragStarted; + public Action DragInProgress; + public Action DragEnded; + public List PointsInSegment; public readonly BindableBool IsSelected = new BindableBool(); @@ -42,12 +45,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private readonly Container marker; private readonly Drawable markerRing; - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } - - [Resolved(CanBeNull = true)] - private IPositionSnapProvider snapProvider { get; set; } - [Resolved] private OsuColour colours { get; set; } @@ -138,6 +135,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components updateMarkerDisplay(); } + // Used to pair up mouse down/drag events with their corresponding mouse up events, + // to avoid deselecting the piece by accident when the mouse up corresponding to the mouse down/drag fires. + private bool keepSelection; + protected override bool OnMouseDown(MouseDownEvent e) { if (RequestSelection == null) @@ -146,22 +147,41 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components switch (e.Button) { case MouseButton.Left: + // if control is pressed, do not do anything as the user may be adding to current selection + // or dragging all currently selected control points. + // if it isn't and the user's intent is to deselect, deselection will happen on mouse up. + if (e.ControlPressed && IsSelected.Value) + return true; + RequestSelection.Invoke(this, e); + keepSelection = true; + return true; case MouseButton.Right: if (!IsSelected.Value) RequestSelection.Invoke(this, e); + + keepSelection = true; return false; // Allow context menu to show } return false; } - protected override bool OnClick(ClickEvent e) => RequestSelection != null; + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); - private Vector2 dragStartPosition; - private PathType? dragPathType; + // ctrl+click deselects this piece, but only if this event + // wasn't immediately preceded by a matching mouse down or drag. + if (IsSelected.Value && e.ControlPressed && !keepSelection) + IsSelected.Value = false; + + keepSelection = false; + } + + protected override bool OnClick(ClickEvent e) => RequestSelection != null; protected override bool OnDragStart(DragStartEvent e) { @@ -170,54 +190,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (e.Button == MouseButton.Left) { - dragStartPosition = ControlPoint.Position; - dragPathType = PointsInSegment[0].Type; - - changeHandler?.BeginChange(); + DragStarted?.Invoke(ControlPoint); + keepSelection = true; return true; } return false; } - protected override void OnDrag(DragEvent e) - { - Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position).ToArray(); - var oldPosition = slider.Position; - double oldStartTime = slider.StartTime; + protected override void OnDrag(DragEvent e) => DragInProgress?.Invoke(e); - if (ControlPoint == slider.Path.ControlPoints[0]) - { - // Special handling for the head control point - the position of the slider changes which means the snapped position and time have to be taken into account - var result = snapProvider?.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition); - - Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? e.ScreenSpaceMousePosition) - slider.Position; - - slider.Position += movementDelta; - slider.StartTime = result?.Time ?? slider.StartTime; - - // Since control points are relative to the position of the slider, they all need to be offset backwards by the delta - for (int i = 1; i < slider.Path.ControlPoints.Count; i++) - slider.Path.ControlPoints[i].Position -= movementDelta; - } - else - ControlPoint.Position = dragStartPosition + (e.MousePosition - e.MouseDownPosition); - - if (!slider.Path.HasValidLength) - { - for (int i = 0; i < slider.Path.ControlPoints.Count; i++) - slider.Path.ControlPoints[i].Position = oldControlPoints[i]; - - slider.Position = oldPosition; - slider.StartTime = oldStartTime; - return; - } - - // Maintain the path type in case it got defaulted to bezier at some point during the drag. - PointsInSegment[0].Type = dragPathType; - } - - protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange(); + protected override void OnDragEnd(DragEndEvent e) => DragEnded?.Invoke(); private void cachePoints(Slider slider) => PointsInSegment = slider.Path.PointsInSegment(ControlPoint); @@ -267,7 +250,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components switch (pathType) { case PathType.Catmull: - return colours.Seafoam; + return colours.SeaFoam; case PathType.Bezier: return colours.Pink; 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 1be9b5bf2e..ae4141073e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using Humanizer; using osu.Framework.Allocation; @@ -16,6 +17,7 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; @@ -40,6 +42,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public Action> RemoveControlPointsRequested; + [Resolved(CanBeNull = true)] + private IPositionSnapProvider snapProvider { get; set; } + public PathControlPointVisualiser(Slider slider, bool allowSelection) { this.slider = slider; @@ -64,6 +69,39 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components controlPoints.BindTo(slider.Path.ControlPoints); } + /// + /// Selects the corresponding to the given , + /// and deselects all other s. + /// + public void SetSelectionTo(PathControlPoint pathControlPoint) + { + foreach (var p in Pieces) + p.IsSelected.Value = p.ControlPoint == pathControlPoint; + } + + /// + /// Delete all visually selected s. + /// + /// + public bool DeleteSelected() + { + List toRemove = Pieces.Where(p => p.IsSelected.Value).Select(p => p.ControlPoint).ToList(); + + // Ensure that there are any points to be deleted + if (toRemove.Count == 0) + return false; + + changeHandler?.BeginChange(); + RemoveControlPointsRequested?.Invoke(toRemove); + 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 void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -87,7 +125,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components Pieces.Add(new PathControlPointPiece(slider, point).With(d => { if (allowSelection) - d.RequestSelection = selectPiece; + d.RequestSelection = selectionRequested; + + d.DragStarted = dragStarted; + d.DragInProgress = dragInProgress; + d.DragEnded = dragEnded; })); Connections.Add(new PathControlPointConnectionPiece(slider, e.NewStartingIndex + i)); @@ -119,6 +161,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components protected override bool OnClick(ClickEvent e) { + if (Pieces.Any(piece => piece.IsHovered)) + return false; + foreach (var piece in Pieces) { piece.IsSelected.Value = false; @@ -142,15 +187,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { } - private void selectPiece(PathControlPointPiece piece, MouseButtonEvent e) + private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e) { if (e.Button == MouseButton.Left && inputManager.CurrentState.Keyboard.ControlPressed) piece.IsSelected.Toggle(); else - { - foreach (var p in Pieces) - p.IsSelected.Value = p == piece; - } + SetSelectionTo(piece.ControlPoint); } /// @@ -184,25 +226,87 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } - public bool DeleteSelected() - { - List toRemove = Pieces.Where(p => p.IsSelected.Value).Select(p => p.ControlPoint).ToList(); + #region Drag handling - // Ensure that there are any points to be deleted - if (toRemove.Count == 0) - return false; + private Vector2[] dragStartPositions; + private PathType?[] dragPathTypes; + private int draggedControlPointIndex; + private HashSet selectedControlPoints; + + private void dragStarted(PathControlPoint controlPoint) + { + dragStartPositions = slider.Path.ControlPoints.Select(point => point.Position).ToArray(); + dragPathTypes = slider.Path.ControlPoints.Select(point => point.Type).ToArray(); + draggedControlPointIndex = slider.Path.ControlPoints.IndexOf(controlPoint); + selectedControlPoints = new HashSet(Pieces.Where(piece => piece.IsSelected.Value).Select(piece => piece.ControlPoint)); + + Debug.Assert(draggedControlPointIndex >= 0); changeHandler?.BeginChange(); - RemoveControlPointsRequested?.Invoke(toRemove); - 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 void dragInProgress(DragEvent e) + { + Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position).ToArray(); + var oldPosition = slider.Position; + double oldStartTime = slider.StartTime; + + if (selectedControlPoints.Contains(slider.Path.ControlPoints[0])) + { + // Special handling for selections containing head control point - the position of the slider changes which means the snapped position and time have to be taken into account + Vector2 newHeadPosition = Parent.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); + var result = snapProvider?.SnapScreenSpacePositionToValidTime(newHeadPosition); + + Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - slider.Position; + + slider.Position += movementDelta; + slider.StartTime = result?.Time ?? slider.StartTime; + + for (int i = 1; i < slider.Path.ControlPoints.Count; i++) + { + var controlPoint = slider.Path.ControlPoints[i]; + // Since control points are relative to the position of the slider, all points that are _not_ selected + // need to be offset _back_ by the delta corresponding to the movement of the head point. + // All other selected control points (if any) will move together with the head point + // (and so they will not move at all, relative to each other). + if (!selectedControlPoints.Contains(controlPoint)) + controlPoint.Position -= movementDelta; + } + } + else + { + for (int i = 0; i < controlPoints.Count; ++i) + { + var controlPoint = controlPoints[i]; + if (selectedControlPoints.Contains(controlPoint)) + controlPoint.Position = dragStartPositions[i] + (e.MousePosition - e.MouseDownPosition); + } + } + + // Snap the path to the current beat divisor before checking length validity. + slider.SnapTo(snapProvider); + + if (!slider.Path.HasValidLength) + { + for (int i = 0; i < slider.Path.ControlPoints.Count; i++) + slider.Path.ControlPoints[i].Position = oldControlPoints[i]; + + slider.Position = oldPosition; + slider.StartTime = oldStartTime; + // Snap the path length again to undo the invalid length. + slider.SnapTo(snapProvider); + return; + } + + // Maintain the path types in case they got defaulted to bezier at some point during the drag. + for (int i = 0; i < slider.Path.ControlPoints.Count; i++) + slider.Path.ControlPoints[i].Type = dragPathTypes[i]; + } + + private void dragEnded() => changeHandler?.EndChange(); + + #endregion + public MenuItem[] ContextMenuItems { get diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 07b6a1bdc2..b868c9a7ee 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -50,7 +49,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { InternalChildren = new Drawable[] { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 17a62fc61c..6cf2a493a9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -81,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders controlPoints.BindTo(HitObject.Path.ControlPoints); pathVersion.BindTo(HitObject.Path.Version); - pathVersion.BindValueChanged(_ => updatePath()); + pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject)); BodyPiece.UpdateFrom(HitObject); } @@ -140,7 +139,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders case MouseButton.Left: if (e.ControlPressed && IsSelected) { - placementControlPointIndex = addControlPoint(e.MousePosition); + changeHandler?.BeginChange(); + placementControlPoint = addControlPoint(e.MousePosition); + ControlPointVisualiser?.SetSelectionTo(placementControlPoint); return true; // Stop input from being handled and modifying the selection } @@ -150,31 +151,22 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return false; } - private int? placementControlPointIndex; + [CanBeNull] + private PathControlPoint placementControlPoint; - protected override bool OnDragStart(DragStartEvent e) - { - if (placementControlPointIndex != null) - { - changeHandler?.BeginChange(); - return true; - } - - return false; - } + protected override bool OnDragStart(DragStartEvent e) => placementControlPoint != null; protected override void OnDrag(DragEvent e) { - Debug.Assert(placementControlPointIndex != null); - - HitObject.Path.ControlPoints[placementControlPointIndex.Value].Position = e.MousePosition - HitObject.Position; + if (placementControlPoint != null) + placementControlPoint.Position = e.MousePosition - HitObject.Position; } - protected override void OnDragEnd(DragEndEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (placementControlPointIndex != null) + if (placementControlPoint != null) { - placementControlPointIndex = null; + placementControlPoint = null; changeHandler?.EndChange(); } } @@ -193,7 +185,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return false; } - private int addControlPoint(Vector2 position) + private PathControlPoint addControlPoint(Vector2 position) { position -= HitObject.Position; @@ -211,10 +203,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } - // Move the control points from the insertion index onwards to make room for the insertion - controlPoints.Insert(insertionIndex, new PathControlPoint { Position = position }); + var pathControlPoint = new PathControlPoint { Position = position }; - return insertionIndex; + // Move the control points from the insertion index onwards to make room for the insertion + controlPoints.Insert(insertionIndex, pathControlPoint); + + HitObject.SnapTo(composer); + + return pathControlPoint; } private void removeControlPoints(List toRemove) @@ -233,7 +229,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders controlPoints.Remove(c); } - // If there are 0 or 1 remaining control points, the slider is in a degenerate (single point) form and should be deleted + // Snap the slider to the current beat divisor before checking length validity. + HitObject.SnapTo(composer); + + // If there are 0 or 1 remaining control points, or the slider has an invalid length, it is in a degenerate form and should be deleted if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength) { placementHandler?.Delete(HitObject); @@ -248,12 +247,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HitObject.Position += first; } - private void updatePath() - { - HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; - editorBeatmap?.Update(HitObject); - } - private void convertToStream() { if (editorBeatmap == null || changeHandler == null || beatDivisor == null) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 4a57d36eb4..efbac5439c 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,15 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; using osu.Game.Extensions; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -17,6 +22,9 @@ namespace osu.Game.Rulesets.Osu.Edit { public class OsuSelectionHandler : EditorSelectionHandler { + [Resolved(CanBeNull = true)] + private IPositionSnapProvider? positionSnapProvider { get; set; } + /// /// During a transform, the initial origin is stored so it can be used throughout the operation. /// @@ -26,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Edit /// During a transform, the initial path types of a single selected slider are stored so they /// can be maintained throughout the operation. /// - private List referencePathTypes; + private List? referencePathTypes; protected override void OnSelectionChanged() { @@ -84,18 +92,28 @@ namespace osu.Game.Rulesets.Osu.Edit return true; } - public override bool HandleFlip(Direction direction) + public override bool HandleFlip(Direction direction, bool flipOverOrigin) { var hitObjects = selectedMovableObjects; - var selectedObjectsQuad = getSurroundingQuad(hitObjects); + var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : getSurroundingQuad(hitObjects); + + bool didFlip = false; foreach (var h in hitObjects) { - h.Position = GetFlippedPosition(direction, selectedObjectsQuad, h.Position); + var flippedPosition = GetFlippedPosition(direction, flipQuad, h.Position); + + if (!Precision.AlmostEquals(flippedPosition, h.Position)) + { + h.Position = flippedPosition; + didFlip = true; + } if (h is Slider slider) { + didFlip = true; + foreach (var point in slider.Path.ControlPoints) { point.Position = new Vector2( @@ -106,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Edit } } - return true; + return didFlip; } public override bool HandleScale(Vector2 scale, Anchor reference) @@ -186,6 +204,10 @@ namespace osu.Game.Rulesets.Osu.Edit for (int i = 0; i < slider.Path.ControlPoints.Count; ++i) slider.Path.ControlPoints[i].Type = referencePathTypes[i]; + // Snap the slider's length to the current beat divisor + // to calculate the final resulting duration / bounding box before the final checks. + slider.SnapTo(positionSnapProvider); + //if sliderhead or sliderend end up outside playfield, revert scaling. Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider }); (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); @@ -195,6 +217,9 @@ namespace osu.Game.Rulesets.Osu.Edit foreach (var point in slider.Path.ControlPoints) point.Position = oldControlPoints.Dequeue(); + + // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap. + slider.SnapTo(positionSnapProvider); } private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index 31474e8fbb..098c639949 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Spun Out"; public override string Acronym => "SO"; - public override IconUsage? Icon => OsuIcon.ModSpunout; + public override IconUsage? Icon => OsuIcon.ModSpunOut; public override ModType Type => ModType.Automation; public override string Description => @"Spinners will be automatically completed."; public override double ScoreMultiplier => 0.9; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index ec87d3bfdf..c6db02ee02 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; -using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -69,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 0ad8e4ea68..1eddfb7fef 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -65,8 +65,8 @@ namespace osu.Game.Rulesets.Osu.Objects double startTime = StartTime + (float)(i + 1) / totalSpins * Duration; AddNested(i < SpinsRequired - ? new SpinnerTick { StartTime = startTime } - : new SpinnerBonusTick { StartTime = startTime }); + ? new SpinnerTick { StartTime = startTime, Position = Position } + : new SpinnerBonusTick { StartTime = startTime, Position = Position }); } } diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index 7314021a14..5c6b907e42 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.ComponentModel; +using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges.Events; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu @@ -39,6 +41,19 @@ namespace osu.Game.Rulesets.Osu return base.Handle(e); } + protected override bool HandleMouseTouchStateChange(TouchStateChangeEvent e) + { + if (!AllowUserCursorMovement) + { + // Still allow for forwarding of the "touch" part, but replace the positional data with that of the mouse. + // Primarily relied upon by the "autopilot" osu! mod. + var touch = new Touch(e.Touch.Source, CurrentState.Mouse.Position); + e = new TouchStateChangeEvent(e.State, e.Input, touch, e.IsActive, null); + } + + return base.HandleMouseTouchStateChange(e); + } + private class OsuKeyBindingContainer : RulesetKeyBindingContainer { public bool AllowUserPresses = true; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs index f8a6e1d3c9..a1184a15cd 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs @@ -3,15 +3,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Skinning.Default { public class SpinnerBackgroundLayer : SpinnerFill { [BackgroundDependencyLoader] - private void load(OsuColour colours, DrawableHitObject drawableHitObject) + private void load() { Disc.Alpha = 0; Anchor = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs new file mode 100644 index 0000000000..4ee28d05b5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.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 osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; + +#nullable enable + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + internal class KiaiFlashingDrawable : BeatSyncedContainer + { + private readonly Drawable flashingDrawable; + + private const float flash_opacity = 0.3f; + + public KiaiFlashingDrawable(Func creationFunc) + { + AutoSizeAxes = Axes.Both; + + Children = new[] + { + (creationFunc.Invoke() ?? Empty()).With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }), + flashingDrawable = (creationFunc.Invoke() ?? Empty()).With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + d.Alpha = 0; + d.Blending = BlendingParameters.Additive; + }) + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + flashingDrawable + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75f); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs deleted file mode 100644 index 4a1d69ad41..0000000000 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs +++ /dev/null @@ -1,61 +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 osu.Framework.Audio.Track; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Containers; - -namespace osu.Game.Rulesets.Osu.Skinning.Legacy -{ - internal class KiaiFlashingSprite : BeatSyncedContainer - { - private readonly Sprite mainSprite; - private readonly Sprite flashingSprite; - - public Texture Texture - { - set - { - mainSprite.Texture = value; - flashingSprite.Texture = value; - } - } - - private const float flash_opacity = 0.3f; - - public KiaiFlashingSprite() - { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - mainSprite = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - flashingSprite = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0, - Blending = BlendingParameters.Additive, - } - }; - } - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - if (!effectPoint.KiaiMode) - return; - - flashingSprite - .FadeTo(flash_opacity) - .Then() - .FadeOut(timingPoint.BeatLength * 0.75f); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs index 611ddd08eb..b511444c44 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private GameplayState gameplayState { get; set; } [BackgroundDependencyLoader] - private void load(ISkinSource skin, OsuColour colours) + private void load(ISkinSource skin) { var texture = skin.GetTexture("star2"); var starBreakAdditive = skin.GetConfig(OsuSkinColour.StarBreakAdditive)?.Value ?? new Color4(255, 182, 193, 255); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index d2f84dcf84..c6007885be 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -68,13 +69,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // at this point, any further texture fetches should be correctly using the priority source if the base texture was retrieved using it. // the flow above handles the case where a sliderendcircle.png is retrieved from the skin, but sliderendcircleoverlay.png doesn't exist. // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png (potentially from the default/fall-through skin). - Texture overlayTexture = getTextureWithFallback("overlay"); InternalChildren = new[] { - hitCircleSprite = new KiaiFlashingSprite + hitCircleSprite = new KiaiFlashingDrawable(() => new Sprite { Texture = baseTexture }) { - Texture = baseTexture, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, @@ -82,9 +81,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = hitCircleOverlay = new KiaiFlashingSprite + Child = hitCircleOverlay = new KiaiFlashingDrawable(() => getAnimationWithFallback(@"overlay", 1000 / 2d)) { - Texture = overlayTexture, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, @@ -126,6 +124,21 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return tex ?? skin.GetTexture($"hitcircle{name}"); } + + Drawable getAnimationWithFallback(string name, double frameLength) + { + Drawable animation = null; + + if (!string.IsNullOrEmpty(priorityLookup)) + { + animation = skin.GetAnimation($"{priorityLookup}{name}", true, true, frameLength: frameLength); + + if (!allowFallback) + return animation; + } + + return animation ?? skin.GetAnimation($"hitcircle{name}", true, true, frameLength: frameLength); + } } protected override void LoadComplete() diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 6953e66b5c..7b9cf8e1d1 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -12,6 +12,8 @@ namespace osu.Game.Rulesets.Osu.Skinning CursorExpand, CursorRotate, HitCircleOverlayAboveNumber, + + // ReSharper disable once IdentifierTypo HitCircleOverlayAboveNumer, // Some old skins will have this typo SpinnerFrequencyModulate, SpinnerNoBlink diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index d1d9ee9f4d..b60ea5da21 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private OsuConfigManager config { get; set; } [BackgroundDependencyLoader(true)] - private void load(OsuConfigManager config, OsuRulesetConfigManager rulesetConfig) + private void load(OsuRulesetConfigManager rulesetConfig) { rulesetConfig?.BindWith(OsuRulesetSetting.ShowCursorTrail, showTrail); } diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs index 1128a0d37f..e4f4bbfd53 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs @@ -2,13 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using Android.App; -using Android.Content.PM; using osu.Framework.Android; using osu.Game.Tests; namespace osu.Game.Rulesets.Taiko.Tests.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.SensorLandscape, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)] + [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] public class MainActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuTestBrowser(); diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist index 5fe822946a..ac658cd14e 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs index 269a855219..16c4148d15 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -83,7 +83,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning private BarLine createBarLineAtCurrentTime(bool major = false) { - var barline = new BarLine + var barLine = new BarLine { Major = major, StartTime = Time.Current + 2000, @@ -92,9 +92,9 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var cpi = new ControlPointInfo(); cpi.Add(0, new TimingControlPoint { BeatLength = 500 }); - barline.ApplyDefaults(cpi, new BeatmapDifficulty()); + barLine.ApplyDefaults(cpi, new BeatmapDifficulty()); - return barline; + return barLine; } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs index b6db333dc9..b3f6a733d3 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs @@ -12,7 +12,6 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Taiko.Tests { [TestFixture] - [Timeout(10000)] public class TaikoBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs index 16a0726c8c..41fe63a553 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps public override IEnumerable GetStatistics() { int hits = HitObjects.Count(s => s is Hit); - int drumrolls = HitObjects.Count(s => s is DrumRoll); + int drumRolls = HitObjects.Count(s => s is DrumRoll); int swells = HitObjects.Count(s => s is Swell); return new[] @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps { Name = @"Drumroll Count", CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), - Content = drumrolls.ToString(), + Content = drumRolls.ToString(), }, new BeatmapStatistic { diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index b2b5d056c3..31f5a6f570 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -9,14 +9,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyAttributes : DifficultyAttributes { - [JsonProperty("stamina_strain")] - public double StaminaStrain { get; set; } + [JsonProperty("stamina_difficulty")] + public double StaminaDifficulty { get; set; } - [JsonProperty("rhythm_strain")] - public double RhythmStrain { get; set; } + [JsonProperty("rhythm_difficulty")] + public double RhythmDifficulty { get; set; } - [JsonProperty("colour_strain")] - public double ColourStrain { get; set; } + [JsonProperty("colour_difficulty")] + public double ColourDifficulty { get; set; } [JsonProperty("approach_rate")] public double ApproachRate { get; set; } @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return v; yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); - yield return (ATTRIB_ID_STRAIN, StarRating); + yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); } @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty base.FromDatabaseAttributes(values); MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; - StarRating = values[ATTRIB_ID_STRAIN]; + StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index e84bee3d28..6afdef3f3c 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -91,9 +91,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { StarRating = starRating, Mods = mods, - StaminaStrain = staminaRating, - RhythmStrain = rhythmRating, - ColourStrain = colourRating, + StaminaDifficulty = staminaRating, + RhythmDifficulty = rhythmRating, + ColourDifficulty = colourRating, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, MaxCombo = beatmap.HitObjects.Count(h => h is Hit), }; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs new file mode 100644 index 0000000000..80552880ea --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.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. + +using Newtonsoft.Json; +using osu.Game.Rulesets.Difficulty; + +namespace osu.Game.Rulesets.Taiko.Difficulty +{ + public class TaikoPerformanceAttributes : PerformanceAttributes + { + [JsonProperty("difficulty")] + public double Difficulty { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 90dd733dfd..bcd55f8fae 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { } - public override double Calculate(Dictionary categoryDifficulty = null) + public override PerformanceAttributes Calculate() { mods = Score.Mods; countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); @@ -35,7 +35,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); - // Custom multipliers for NoFail and SpunOut. double multiplier = 1.1; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things if (mods.Any(m => m is ModNoFail)) @@ -44,43 +43,38 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (mods.Any(m => m is ModHidden)) multiplier *= 1.10; - double strainValue = computeStrainValue(); + double difficultyValue = computeDifficultyValue(); double accuracyValue = computeAccuracyValue(); double totalValue = Math.Pow( - Math.Pow(strainValue, 1.1) + + Math.Pow(difficultyValue, 1.1) + Math.Pow(accuracyValue, 1.1), 1.0 / 1.1 ) * multiplier; - if (categoryDifficulty != null) + return new TaikoPerformanceAttributes { - categoryDifficulty["Strain"] = strainValue; - categoryDifficulty["Accuracy"] = accuracyValue; - } - - return totalValue; + Difficulty = difficultyValue, + Accuracy = accuracyValue, + Total = totalValue + }; } - private double computeStrainValue() + private double computeDifficultyValue() { - double strainValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0; + double difficultyValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0; - // Longer maps are worth more double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); - strainValue *= lengthBonus; + difficultyValue *= lengthBonus; - // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available - strainValue *= Math.Pow(0.985, countMiss); + difficultyValue *= Math.Pow(0.985, countMiss); if (mods.Any(m => m is ModHidden)) - strainValue *= 1.025; + difficultyValue *= 1.025; if (mods.Any(m => m is ModFlashlight)) - // Apply length bonus again if flashlight is on simply because it becomes a lot harder on longer maps. - strainValue *= 1.05 * lengthBonus; + difficultyValue *= 1.05 * lengthBonus; - // Scale the speed value with accuracy _slightly_ - return strainValue * Score.Accuracy; + return difficultyValue * Score.Accuracy; } private double computeAccuracyValue() @@ -88,11 +82,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (Attributes.GreatHitWindow <= 0) return 0; - // Lots of arbitrary values from testing. - // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution double accValue = Math.Pow(150.0 / Attributes.GreatHitWindow, 1.1) * Math.Pow(Score.Accuracy, 15) * 22.0; - // Bonus for many hitcircles - it's harder to keep good accuracy up for longer + // 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)); } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs index 455b2fc596..25f895708f 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Objects; using osuTK; @@ -19,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { AccentColour = Hit.COLOUR_CENTRE; } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs index bd21d511b1..c6165495d8 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Objects; using osuTK; using osuTK.Graphics; @@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { AccentColour = Hit.COLOUR_RIM; } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs index e1063e1071..7ba2618a63 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs @@ -7,7 +7,6 @@ using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; @@ -39,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.UI } [BackgroundDependencyLoader(true)] - private void load(TextureStore textures, GameplayState gameplayState) + private void load(GameplayState gameplayState) { InternalChildren = new[] { diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 861b800038..16be20f7f3 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -12,7 +12,6 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.UI; -using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK; @@ -149,9 +148,6 @@ namespace osu.Game.Rulesets.Taiko.UI centreHit.Colour = colours.Pink; } - [Resolved(canBeNull: true)] - private GameplayClock gameplayClock { get; set; } - public bool OnPressed(KeyBindingPressEvent e) { Drawable target = null; diff --git a/osu.Game.Tests.Android/MainActivity.cs b/osu.Game.Tests.Android/MainActivity.cs index 0695c8e37b..dbe74a10da 100644 --- a/osu.Game.Tests.Android/MainActivity.cs +++ b/osu.Game.Tests.Android/MainActivity.cs @@ -2,12 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using Android.App; -using Android.Content.PM; using osu.Framework.Android; namespace osu.Game.Tests.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.SensorLandscape, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = true)] + [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] public class MainActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuTestBrowser(); diff --git a/osu.Game.Tests.iOS/Info.plist b/osu.Game.Tests.iOS/Info.plist index 98a4223116..1a89345bc5 100644 --- a/osu.Game.Tests.iOS/Info.plist +++ b/osu.Game.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 6e5a546e87..9ac7838821 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -2,14 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; +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.Rulesets.Taiko; using osu.Game.Scoring; @@ -21,6 +27,14 @@ namespace osu.Game.Tests.Beatmaps.Formats [TestFixture] public class LegacyScoreDecoderTest { + private CultureInfo originalCulture; + + [SetUp] + public void SetUp() + { + originalCulture = CultureInfo.CurrentCulture; + } + [Test] public void TestDecodeManiaReplay() { @@ -44,6 +58,58 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestCultureInvariance() + { + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + var beatmap = new TestBeatmap(ruleset); + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) + } + } + }; + + // the "se" culture is used here, as it encodes the negative number sign as U+2212 MINUS SIGN, + // rather than the classic ASCII U+002D HYPHEN-MINUS. + CultureInfo.CurrentCulture = new CultureInfo("se"); + + var encodeStream = new MemoryStream(); + + var encoder = new LegacyScoreEncoder(score, beatmap); + encoder.Encode(encodeStream); + + var decodeStream = new MemoryStream(encodeStream.GetBuffer()); + + var decoder = new TestLegacyScoreDecoder(); + var decodedAfterEncode = decoder.Parse(decodeStream); + + Assert.Multiple(() => + { + Assert.That(decodedAfterEncode, Is.Not.Null); + + Assert.That(decodedAfterEncode.ScoreInfo.User.Username, Is.EqualTo(scoreInfo.User.Username)); + Assert.That(decodedAfterEncode.ScoreInfo.Ruleset, Is.EqualTo(scoreInfo.Ruleset)); + Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(scoreInfo.TotalScore)); + Assert.That(decodedAfterEncode.ScoreInfo.MaxCombo, Is.EqualTo(scoreInfo.MaxCombo)); + Assert.That(decodedAfterEncode.ScoreInfo.Date, Is.EqualTo(scoreInfo.Date)); + + Assert.That(decodedAfterEncode.Replay.Frames.Count, Is.EqualTo(1)); + }); + } + + [TearDown] + public void TearDown() + { + CultureInfo.CurrentCulture = originalCulture; + } + private class TestLegacyScoreDecoder : LegacyScoreDecoder { private static readonly Dictionary rulesets = new Ruleset[] diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index 6e2b9d20a8..c02141bf9f 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportWhenClosed() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -57,7 +57,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportThenDelete() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -78,7 +78,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportThenDeleteFromStream() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -93,7 +93,7 @@ namespace osu.Game.Tests.Beatmaps.IO using (var stream = File.OpenRead(tempPath)) { importedSet = await manager.Import(new ImportTask(stream, Path.GetFileName(tempPath))); - ensureLoaded(osu); + await ensureLoaded(osu); } Assert.IsTrue(File.Exists(tempPath), "Stream source file somehow went missing"); @@ -114,7 +114,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportThenImport() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -140,7 +140,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenImportWithReZip() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -171,7 +171,7 @@ namespace osu.Game.Tests.Beatmaps.IO var importedSecondTime = await osu.Dependencies.Get().Import(new ImportTask(temp)); - ensureLoaded(osu); + await ensureLoaded(osu); // but contents doesn't, so existing should still be used. Assert.IsTrue(imported.ID == importedSecondTime.Value.ID); @@ -192,7 +192,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenImportWithChangedHashedFile() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -225,7 +225,7 @@ namespace osu.Game.Tests.Beatmaps.IO var importedSecondTime = await osu.Dependencies.Get().Import(new ImportTask(temp)); - ensureLoaded(osu); + await ensureLoaded(osu); // check the newly "imported" beatmap is not the original. Assert.IsTrue(imported.ID != importedSecondTime.Value.ID); @@ -247,7 +247,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Ignore("intentionally broken by import optimisations")] public async Task TestImportThenImportWithChangedFile() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -277,7 +277,7 @@ namespace osu.Game.Tests.Beatmaps.IO var importedSecondTime = await osu.Dependencies.Get().Import(new ImportTask(temp)); - ensureLoaded(osu); + await ensureLoaded(osu); // check the newly "imported" beatmap is not the original. Assert.IsTrue(imported.ID != importedSecondTime.Value.ID); @@ -298,7 +298,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenImportWithDifferentFilename() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -328,7 +328,7 @@ namespace osu.Game.Tests.Beatmaps.IO var importedSecondTime = await osu.Dependencies.Get().Import(new ImportTask(temp)); - ensureLoaded(osu); + await ensureLoaded(osu); // check the newly "imported" beatmap is not the original. Assert.IsTrue(imported.ID != importedSecondTime.Value.ID); @@ -351,7 +351,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportCorruptThenImport() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -392,7 +392,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestModelCreationFailureDoesntReturn() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -428,7 +428,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestRollbackOnFailure() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -507,7 +507,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportThenDeleteThenImport() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -534,7 +534,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportThenDeleteThenImportWithOnlineIDsMissing() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}")) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -566,7 +566,7 @@ namespace osu.Game.Tests.Beatmaps.IO public async Task TestImportWithDuplicateBeatmapIDs() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -621,8 +621,8 @@ namespace osu.Game.Tests.Beatmaps.IO [NonParallelizable] public void TestImportOverIPC() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-host", true)) - using (HeadlessGameHost client = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-client", true)) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(true)) + using (HeadlessGameHost client = new CleanRunHeadlessGameHost(true)) { try { @@ -637,7 +637,7 @@ namespace osu.Game.Tests.Beatmaps.IO if (!importer.ImportAsync(temp).Wait(10000)) Assert.Fail(@"IPC took too long to send"); - ensureLoaded(osu); + ensureLoaded(osu).WaitSafely(); waitForOrAssert(() => !File.Exists(temp), "Temporary still exists after IPC import", 5000); } @@ -651,7 +651,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWhenFileOpen() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -659,7 +659,7 @@ namespace osu.Game.Tests.Beatmaps.IO string temp = TestResources.GetTestBeatmapForImport(); using (File.OpenRead(temp)) await osu.Dependencies.Get().Import(temp); - ensureLoaded(osu); + await ensureLoaded(osu); File.Delete(temp); Assert.IsFalse(File.Exists(temp), "We likely held a read lock on the file when we shouldn't"); } @@ -673,7 +673,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWithDuplicateHashes() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -698,7 +698,7 @@ namespace osu.Game.Tests.Beatmaps.IO await osu.Dependencies.Get().Import(temp); - ensureLoaded(osu); + await ensureLoaded(osu); } finally { @@ -715,7 +715,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportNestedStructure() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -741,7 +741,7 @@ namespace osu.Game.Tests.Beatmaps.IO var imported = await osu.Dependencies.Get().Import(new ImportTask(temp)); - ensureLoaded(osu); + await ensureLoaded(osu); Assert.IsFalse(imported.Value.Files.Any(f => f.Filename.Contains("subfolder")), "Files contain common subfolder"); } @@ -760,7 +760,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWithIgnoredDirectoryInArchive() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -794,7 +794,7 @@ namespace osu.Game.Tests.Beatmaps.IO var imported = await osu.Dependencies.Get().Import(new ImportTask(temp)); - ensureLoaded(osu); + await ensureLoaded(osu); Assert.IsFalse(imported.Value.Files.Any(f => f.Filename.Contains("__MACOSX")), "Files contain resource fork folder, which should be ignored"); Assert.IsFalse(imported.Value.Files.Any(f => f.Filename.Contains("actual_data")), "Files contain common subfolder"); @@ -812,9 +812,9 @@ namespace osu.Game.Tests.Beatmaps.IO } [Test] - public async Task TestUpdateBeatmapInfo() + public void TestUpdateBeatmapInfo() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -822,7 +822,8 @@ namespace osu.Game.Tests.Beatmaps.IO var manager = osu.Dependencies.Get(); string temp = TestResources.GetTestBeatmapForImport(); - await osu.Dependencies.Get().Import(temp); + + osu.Dependencies.Get().Import(temp).WaitSafely(); // Update via the beatmap, not the beatmap info, to ensure correct linking BeatmapSetInfo setToUpdate = manager.GetAllUsableBeatmapSets()[0]; @@ -842,9 +843,9 @@ namespace osu.Game.Tests.Beatmaps.IO } [Test] - public async Task TestUpdateBeatmapFile() + public void TestUpdateBeatmapFile() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -852,7 +853,8 @@ namespace osu.Game.Tests.Beatmaps.IO var manager = osu.Dependencies.Get(); string temp = TestResources.GetTestBeatmapForImport(); - await osu.Dependencies.Get().Import(temp); + + osu.Dependencies.Get().Import(temp).WaitSafely(); BeatmapSetInfo setToUpdate = manager.GetAllUsableBeatmapSets()[0]; @@ -888,7 +890,7 @@ namespace osu.Game.Tests.Beatmaps.IO public void TestSaveRemovesInvalidCharactersFromPath() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -922,7 +924,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public void TestCreateNewEmptyBeatmap() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -949,7 +951,7 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public void TestCreateNewBeatmapWithObject() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -976,35 +978,35 @@ namespace osu.Game.Tests.Beatmaps.IO } } - public static async Task LoadQuickOszIntoOsu(OsuGameBase osu) + public static Task LoadQuickOszIntoOsu(OsuGameBase osu) => Task.Factory.StartNew(() => { string temp = TestResources.GetQuickTestBeatmapForImport(); var manager = osu.Dependencies.Get(); - var importedSet = await manager.Import(new ImportTask(temp)).ConfigureAwait(false); + var importedSet = manager.Import(new ImportTask(temp)).GetResultSafely(); - ensureLoaded(osu); + ensureLoaded(osu).WaitSafely(); waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.Value.ID); - } + }, TaskCreationOptions.LongRunning); - public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) + public static Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) => Task.Factory.StartNew(() => { string temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); var manager = osu.Dependencies.Get(); - var importedSet = await manager.Import(new ImportTask(temp)).ConfigureAwait(false); + var importedSet = manager.Import(new ImportTask(temp)).GetResultSafely(); - ensureLoaded(osu); + ensureLoaded(osu).WaitSafely(); waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.Value.ID); - } + }, TaskCreationOptions.LongRunning); private void deleteBeatmapSet(BeatmapSetInfo imported, OsuGameBase osu) { @@ -1022,7 +1024,7 @@ namespace osu.Game.Tests.Beatmaps.IO { return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo { - OnlineScoreID = 2, + OnlineID = 2, BeatmapInfo = beatmapInfo, BeatmapInfoID = beatmapInfo.ID }, new ImportScoreTest.TestArchiveReader()); @@ -1053,7 +1055,7 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.AreEqual(expected, osu.Dependencies.Get().Get().FileInfo.Count(f => f.ReferenceCount == 1)); } - private static void ensureLoaded(OsuGameBase osu, int timeout = 60000) + private static Task ensureLoaded(OsuGameBase osu, int timeout = 60000) => Task.Factory.StartNew(() => { IEnumerable resultSets = null; var store = osu.Dependencies.Get(); @@ -1089,14 +1091,14 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.IsTrue(beatmap?.HitObjects.Any() == true); beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 3))?.Beatmap; Assert.IsTrue(beatmap?.HitObjects.Any() == true); - } + }, TaskCreationOptions.LongRunning); private static void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) { - Task task = Task.Run(() => + Task task = Task.Factory.StartNew(() => { while (!result()) Thread.Sleep(200); - }); + }, TaskCreationOptions.LongRunning); Assert.IsTrue(task.Wait(timeout), failureMessage); } diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index 2a60a7b96d..26ab8808b9 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; 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.Rulesets; @@ -25,9 +26,6 @@ namespace osu.Game.Tests.Beatmaps private BeatmapSetInfo importedSet; - [Resolved] - private BeatmapManager beatmaps { get; set; } - private TestBeatmapDifficultyCache difficultyCache; private IBindable starDifficultyBindable; @@ -35,7 +33,7 @@ namespace osu.Game.Tests.Beatmaps [BackgroundDependencyLoader] private void load(OsuGameBase osu) { - importedSet = ImportBeatmapTest.LoadQuickOszIntoOsu(osu).Result; + importedSet = ImportBeatmapTest.LoadQuickOszIntoOsu(osu).GetResultSafely(); } [SetUpSteps] diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index af87fc17ad..8def8005f1 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -9,6 +9,21 @@ namespace osu.Game.Tests.Chat [TestFixture] public class MessageFormatterTests { + private string originalWebsiteRootUrl; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + originalWebsiteRootUrl = MessageFormatter.WebsiteRootUrl; + MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + MessageFormatter.WebsiteRootUrl = originalWebsiteRootUrl; + } + [Test] public void TestBareLink() { @@ -32,8 +47,6 @@ namespace osu.Game.Tests.Chat [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions/123", "https://dev.ppy.sh/beatmapsets/discussions/123")] public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link) { - MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; - Message result = MessageFormatter.FormatMessage(new Message { Content = link }); Assert.AreEqual(result.Content, result.DisplayContent); @@ -47,7 +60,10 @@ namespace osu.Game.Tests.Chat [Test] public void TestMultipleComplexLinks() { - Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a http://test.io/link#fragment. (see https://twitter.com). Also, This string should not be altered. http://example.com/" }); + Message result = MessageFormatter.FormatMessage(new Message + { + Content = "This is a http://test.io/link#fragment. (see https://twitter.com). Also, This string should not be altered. http://example.com/" + }); Assert.AreEqual(result.Content, result.DisplayContent); Assert.AreEqual(3, result.Links.Count); @@ -104,7 +120,7 @@ namespace osu.Game.Tests.Chat Assert.AreEqual("This is a Wiki Link.", result.DisplayContent); Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki Link", result.Links[0].Url); + Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki Link", result.Links[0].Url); Assert.AreEqual(10, result.Links[0].Index); Assert.AreEqual(9, result.Links[0].Length); } @@ -117,15 +133,15 @@ namespace osu.Game.Tests.Chat Assert.AreEqual("This is a Wiki Link Wiki:LinkWiki.Link.", result.DisplayContent); Assert.AreEqual(3, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki Link", result.Links[0].Url); + Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki Link", result.Links[0].Url); Assert.AreEqual(10, result.Links[0].Index); Assert.AreEqual(9, result.Links[0].Length); - Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki:Link", result.Links[1].Url); + Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki:Link", result.Links[1].Url); Assert.AreEqual(20, result.Links[1].Index); Assert.AreEqual(9, result.Links[1].Length); - Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki.Link", result.Links[2].Url); + Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki.Link", result.Links[2].Url); Assert.AreEqual(29, result.Links[2].Index); Assert.AreEqual(9, result.Links[2].Length); } @@ -445,12 +461,15 @@ namespace osu.Game.Tests.Chat [Test] public void TestLinkComplex() { - Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [http://www.simple-test.com simple test] with some [traps] and [[wiki links]]. Don't forget to visit https://osu.ppy.sh (now!)[http://google.com]\uD83D\uDE12" }); + Message result = MessageFormatter.FormatMessage(new Message + { + Content = "This is a [http://www.simple-test.com simple test] with some [traps] and [[wiki links]]. Don't forget to visit https://osu.ppy.sh (now!)[http://google.com]\uD83D\uDE12" + }); Assert.AreEqual("This is a simple test with some [traps] and wiki links. Don't forget to visit https://osu.ppy.sh now!\0\0\0", result.DisplayContent); Assert.AreEqual(5, result.Links.Count); - Link f = result.Links.Find(l => l.Url == "https://osu.ppy.sh/wiki/wiki links"); + Link f = result.Links.Find(l => l.Url == "https://dev.ppy.sh/wiki/wiki links"); Assert.That(f, Is.Not.Null); Assert.AreEqual(44, f.Index); Assert.AreEqual(10, f.Length); @@ -514,8 +533,6 @@ namespace osu.Game.Tests.Chat [TestCase("https://dev.ppy.sh/home/changelog/lazer/2021.1012", "lazer/2021.1012")] public void TestChangelogLinks(string link, string expectedArg) { - MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; - LinkDetails result = MessageFormatter.GetLinkDetails(link); Assert.AreEqual(LinkAction.OpenChangelog, result.Action); diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index d87ac29d75..53e4ef07e7 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -6,6 +6,7 @@ using System.IO; 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.Tests.Resources; @@ -128,8 +129,12 @@ namespace osu.Game.Tests.Collections.IO [Test] public async Task TestSaveAndReload() { - using (HeadlessGameHost host = new TestRunHeadlessGameHost("TestSaveAndReload", bypassCleanup: true)) + string firstRunName; + + using (var host = new CleanRunHeadlessGameHost(bypassCleanup: true)) { + firstRunName = host.Name; + try { var osu = LoadOsuIntoHost(host, true); @@ -149,7 +154,8 @@ namespace osu.Game.Tests.Collections.IO } } - using (HeadlessGameHost host = new TestRunHeadlessGameHost("TestSaveAndReload")) + // Name matches the automatically chosen name from `CleanRunHeadlessGameHost` above, so we end up using the same storage location. + using (HeadlessGameHost host = new TestRunHeadlessGameHost(firstRunName)) { try { @@ -174,7 +180,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.Run(() => osu.CollectionManager.Import(stream).Wait()); + await Task.Factory.StartNew(() => osu.CollectionManager.Import(stream).WaitSafely(), TaskCreationOptions.LongRunning); } } } diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index a6edd6cb5f..e47e24021f 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -809,7 +809,7 @@ namespace osu.Game.Tests.Database // TODO: reimplement when we have score support in realm. // return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo // { - // OnlineScoreID = 2, + // OnlineID = 2, // Beatmap = beatmap, // BeatmapInfoID = beatmap.ID // }, new ImportScoreTest.TestArchiveReader()); diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs index 9b6769b788..9432a56741 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -6,6 +6,8 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Models; using Realms; @@ -21,14 +23,41 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realmFactory, _) => { - ILive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(); + ILive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(realmFactory); - ILive beatmap2 = realmFactory.CreateContext().All().First().ToLive(); + ILive beatmap2 = realmFactory.CreateContext().All().First().ToLive(realmFactory); Assert.AreEqual(beatmap, beatmap2); }); } + [Test] + public void TestAccessAfterStorageMigrate() + { + RunTestWithRealm((realmFactory, storage) => + { + var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); + + ILive liveBeatmap; + + using (var context = realmFactory.CreateContext()) + { + context.Write(r => r.Add(beatmap)); + + liveBeatmap = beatmap.ToLive(realmFactory); + } + + using (var migratedStorage = new TemporaryNativeStorage("realm-test-migration-target")) + { + migratedStorage.DeleteDirectory(string.Empty); + + storage.Migrate(migratedStorage); + + Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + } + }); + } + [Test] public void TestAccessAfterAttach() { @@ -36,7 +65,7 @@ namespace osu.Game.Tests.Database { var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); - var liveBeatmap = beatmap.ToLive(); + var liveBeatmap = beatmap.ToLive(realmFactory); using (var context = realmFactory.CreateContext()) context.Write(r => r.Add(beatmap)); @@ -49,7 +78,7 @@ namespace osu.Game.Tests.Database public void TestAccessNonManaged() { var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); - var liveBeatmap = beatmap.ToLive(); + var liveBeatmap = beatmap.ToLiveUnmanaged(); Assert.IsFalse(beatmap.Hidden); Assert.IsFalse(liveBeatmap.Value.Hidden); @@ -74,9 +103,9 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); Debug.Assert(liveBeatmap != null); @@ -87,7 +116,7 @@ namespace osu.Game.Tests.Database Assert.IsTrue(beatmap.IsValid); Assert.IsFalse(beatmap.Hidden); }); - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); }); } @@ -103,9 +132,9 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); Debug.Assert(liveBeatmap != null); @@ -113,7 +142,7 @@ namespace osu.Game.Tests.Database { liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; }); liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); }); - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); }); } @@ -123,7 +152,7 @@ namespace osu.Game.Tests.Database RunTestWithRealm((realmFactory, _) => { var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); - var liveBeatmap = beatmap.ToLive(); + var liveBeatmap = beatmap.ToLive(realmFactory); Assert.DoesNotThrow(() => { @@ -145,9 +174,9 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); Debug.Assert(liveBeatmap != null); @@ -167,7 +196,7 @@ namespace osu.Game.Tests.Database var __ = liveBeatmap.Value; }); } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); }); } @@ -183,9 +212,9 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); Debug.Assert(liveBeatmap != null); @@ -195,7 +224,7 @@ namespace osu.Game.Tests.Database { var unused = liveBeatmap.Value; }); - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); }); } @@ -222,9 +251,9 @@ namespace osu.Game.Tests.Database // not just a refresh from the resolved Live. threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); Debug.Assert(liveBeatmap != null); diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index 04c9f2577a..4e67f09dca 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -10,6 +10,7 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.IO; using osu.Game.Models; #nullable enable @@ -27,15 +28,16 @@ namespace osu.Game.Tests.Database storage.DeleteDirectory(string.Empty); } - protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") + protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(callingMethodName: caller)) { host.Run(new RealmTestGame(() => { - var testStorage = storage.GetStorageForDirectory(caller); + // ReSharper disable once AccessToDisposedClosure + var testStorage = new OsuStorage(host, storage.GetStorageForDirectory(caller)); - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + using (var realmFactory = new RealmContextFactory(testStorage, "client")) { Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); testAction(realmFactory, testStorage); @@ -52,13 +54,13 @@ namespace osu.Game.Tests.Database protected void RunTestWithRealmAsync(Func testAction, [CallerMemberName] string caller = "") { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(callingMethodName: caller)) { host.Run(new RealmTestGame(async () => { var testStorage = storage.GetStorageForDirectory(caller); - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + using (var realmFactory = new RealmContextFactory(testStorage, "client")) { Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); await testAction(realmFactory, testStorage); diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index f4e0838be1..cc7e8a0c97 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -45,9 +45,9 @@ namespace osu.Game.Tests.Database { var rulesets = new RealmRulesetStore(realmFactory, storage); - Assert.IsTrue((rulesets.AvailableRulesets.First() as RealmRuleset)?.IsManaged == false); - Assert.IsTrue((rulesets.GetRuleset(0) as RealmRuleset)?.IsManaged == false); - Assert.IsTrue((rulesets.GetRuleset("mania") as RealmRuleset)?.IsManaged == false); + Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged); + Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged); + Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged); }); } } diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs index 860828ae81..f05d9ab3dc 100644 --- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -52,6 +52,45 @@ namespace osu.Game.Tests.Database Assert.That(queryCount(GlobalAction.Select), Is.EqualTo(2)); } + [Test] + public void TestDefaultsPopulationRemovesExcess() + { + Assert.That(queryCount(), Is.EqualTo(0)); + + KeyBindingContainer testContainer = new TestKeyBindingContainer(); + + // Add some excess bindings for an action which only supports 1. + using (var realm = realmContextFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) + { + realm.Add(new RealmKeyBinding + { + Action = GlobalAction.Back, + KeyCombination = new KeyCombination(InputKey.A) + }); + + realm.Add(new RealmKeyBinding + { + Action = GlobalAction.Back, + KeyCombination = new KeyCombination(InputKey.S) + }); + + realm.Add(new RealmKeyBinding + { + Action = GlobalAction.Back, + KeyCombination = new KeyCombination(InputKey.D) + }); + + transaction.Commit(); + } + + Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(3)); + + keyBindingStore.Register(testContainer, Enumerable.Empty()); + + Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(1)); + } + private int queryCount(GlobalAction? match = null) { using (var realm = realmContextFactory.CreateContext()) diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs index 4ab6e5cef6..91d9a8753c 100644 --- a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs @@ -40,80 +40,80 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestNormalControlPointVolume() { - var hitcircle = new HitCircle + var hitCircle = new HitCircle { StartTime = 0, Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }; - hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - assertOk(new List { hitcircle }); + assertOk(new List { hitCircle }); } [Test] public void TestLowControlPointVolume() { - var hitcircle = new HitCircle + var hitCircle = new HitCircle { StartTime = 1000, Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }; - hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - assertLowVolume(new List { hitcircle }); + assertLowVolume(new List { hitCircle }); } [Test] public void TestMutedControlPointVolume() { - var hitcircle = new HitCircle + var hitCircle = new HitCircle { StartTime = 2000, Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }; - hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - assertMuted(new List { hitcircle }); + assertMuted(new List { hitCircle }); } [Test] public void TestNormalSampleVolume() { // The sample volume should take precedence over the control point volume. - var hitcircle = new HitCircle + var hitCircle = new HitCircle { StartTime = 2000, Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } }; - hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - assertOk(new List { hitcircle }); + assertOk(new List { hitCircle }); } [Test] public void TestLowSampleVolume() { - var hitcircle = new HitCircle + var hitCircle = new HitCircle { StartTime = 2000, Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_low) } }; - hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - assertLowVolume(new List { hitcircle }); + assertLowVolume(new List { hitCircle }); } [Test] public void TestMutedSampleVolume() { - var hitcircle = new HitCircle + var hitCircle = new HitCircle { StartTime = 0, Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } }; - hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - assertMuted(new List { hitcircle }); + assertMuted(new List { hitCircle }); } [Test] diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 3bf6aaac7a..f0ebd7a8cc 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -5,8 +5,10 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics.Audio; @@ -15,6 +17,8 @@ using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Audio; +using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -33,6 +37,9 @@ namespace osu.Game.Tests.Gameplay [HeadlessTest] public class TestSceneStoryboardSamples : OsuTestScene, IStorageResourceProvider { + [Resolved] + private OsuConfigManager config { get; set; } + [Test] public void TestRetrieveTopLevelSample() { @@ -164,10 +171,43 @@ namespace osu.Game.Tests.Gameplay ((IApplicableToRate)testedMod).ApplyToRate(sampleInfo.StartTime))); } + [Test] + public void TestSamplePlaybackWithBeatmapHitsoundsOff() + { + GameplayClockContainer gameplayContainer = null; + TestDrawableStoryboardSample sample = null; + + AddStep("disable beatmap hitsounds", () => config.SetValue(OsuSetting.BeatmapHitsounds, false)); + + AddStep("setup storyboard sample", () => + { + Beatmap.Value = new TestCustomSkinWorkingBeatmap(new OsuRuleset().RulesetInfo, this); + + var beatmapSkinSourceContainer = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); + + Add(gameplayContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) + { + Child = beatmapSkinSourceContainer + }); + + beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) + { + Clock = gameplayContainer.GameplayClock + }); + }); + + AddStep("start", () => gameplayContainer.Start()); + + AddUntilStep("sample played", () => sample.IsPlayed); + AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); + + AddStep("restore default", () => config.GetBindable(OsuSetting.BeatmapHitsounds).SetDefault()); + } + private class TestSkin : LegacySkin { public TestSkin(string resourceName, IStorageResourceProvider resources) - : base(DefaultLegacySkin.Info, new TestResourceStore(resourceName), resources, "skin.ini") + : base(DefaultLegacySkin.CreateInfo(), new TestResourceStore(resourceName), resources, "skin.ini") { } } @@ -183,7 +223,8 @@ namespace osu.Game.Tests.Gameplay public byte[] Get(string name) => name == resourceName ? TestResources.GetStore().Get("Resources/Samples/test-sample.mp3") : null; - public Task GetAsync(string name) => name == resourceName ? TestResources.GetStore().GetAsync("Resources/Samples/test-sample.mp3") : null; + public Task GetAsync(string name, CancellationToken cancellationToken = default) + => name == resourceName ? TestResources.GetStore().GetAsync("Resources/Samples/test-sample.mp3", cancellationToken) : null; public Stream GetStream(string name) => name == resourceName ? TestResources.GetStore().GetStream("Resources/Samples/test-sample.mp3") : null; @@ -220,6 +261,7 @@ namespace osu.Game.Tests.Gameplay public AudioManager AudioManager => Audio; public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; + public RealmContextFactory RealmContextFactory => null; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; #endregion diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index dbeb453d4d..a658a0eaeb 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Game.Collections; using osu.Game.Tests.Resources; @@ -58,7 +59,7 @@ namespace osu.Game.Tests { // Beatmap must be imported before the collection manager is loaded. if (withBeatmap) - BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely(); AddInternal(CollectionManager = new CollectionManager(Storage)); } diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index b612899d79..28937b2120 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -18,9 +18,6 @@ namespace osu.Game.Tests.Input [Resolved] private FrameworkConfigManager frameworkConfigManager { get; set; } - [Resolved] - private OsuConfigManager osuConfigManager { get; set; } - [TestCase(WindowMode.Windowed)] [TestCase(WindowMode.Borderless)] public void TestDisableConfining(WindowMode windowMode) diff --git a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs index ce6b3a68a5..cd6879cf01 100644 --- a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs +++ b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs @@ -34,20 +34,20 @@ namespace osu.Game.Tests.Mods var mod3 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }; var doubleConvertedMod1 = new APIMod(mod1).ToMod(ruleset); - var doulbeConvertedMod2 = new APIMod(mod2).ToMod(ruleset); - var doulbeConvertedMod3 = new APIMod(mod3).ToMod(ruleset); + var doubleConvertedMod2 = new APIMod(mod2).ToMod(ruleset); + var doubleConvertedMod3 = new APIMod(mod3).ToMod(ruleset); Assert.That(mod1, Is.Not.EqualTo(mod2)); - Assert.That(doubleConvertedMod1, Is.Not.EqualTo(doulbeConvertedMod2)); + Assert.That(doubleConvertedMod1, Is.Not.EqualTo(doubleConvertedMod2)); Assert.That(mod2, Is.EqualTo(mod2)); - Assert.That(doulbeConvertedMod2, Is.EqualTo(doulbeConvertedMod2)); + Assert.That(doubleConvertedMod2, Is.EqualTo(doubleConvertedMod2)); Assert.That(mod2, Is.EqualTo(mod3)); - Assert.That(doulbeConvertedMod2, Is.EqualTo(doulbeConvertedMod3)); + Assert.That(doubleConvertedMod2, Is.EqualTo(doubleConvertedMod3)); Assert.That(mod3, Is.EqualTo(mod2)); - Assert.That(doulbeConvertedMod3, Is.EqualTo(doulbeConvertedMod2)); + Assert.That(doubleConvertedMod3, Is.EqualTo(doubleConvertedMod2)); } } } diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index 8a063b3c6e..4bb54f1625 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.NonVisual { try { - string defaultStorageLocation = getDefaultLocationFor(nameof(TestDefaultDirectory)); + string defaultStorageLocation = getDefaultLocationFor(host); var osu = LoadOsuIntoHost(host); var storage = osu.Dependencies.Get(); @@ -61,6 +61,7 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); } } } @@ -94,6 +95,7 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); } } } @@ -107,7 +109,7 @@ namespace osu.Game.Tests.NonVisual { try { - string defaultStorageLocation = getDefaultLocationFor(nameof(TestMigration)); + string defaultStorageLocation = getDefaultLocationFor(host); var osu = LoadOsuIntoHost(host); var storage = osu.Dependencies.Get(); @@ -160,6 +162,7 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); } } } @@ -168,7 +171,7 @@ namespace osu.Game.Tests.NonVisual public void TestMigrationBetweenTwoTargets() { string customPath = prepareCustomPath(); - string customPath2 = prepareCustomPath("-2"); + string customPath2 = prepareCustomPath(); using (var host = new CustomTestHeadlessGameHost()) { @@ -185,7 +188,7 @@ namespace osu.Game.Tests.NonVisual Assert.That(File.Exists(Path.Combine(customPath2, database_filename))); // some files may have been left behind for whatever reason, but that's not what we're testing here. - customPath = prepareCustomPath(); + cleanupPath(customPath); Assert.DoesNotThrow(() => osu.Migrate(customPath)); Assert.That(File.Exists(Path.Combine(customPath, database_filename))); @@ -193,6 +196,8 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); + cleanupPath(customPath2); } } } @@ -214,6 +219,7 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); } } } @@ -243,6 +249,7 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); } } } @@ -272,13 +279,14 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); + cleanupPath(customPath); } } } - private static string getDefaultLocationFor(string testTypeName) + private static string getDefaultLocationFor(CustomTestHeadlessGameHost host) { - string path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, testTypeName); + string path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, host.Name); if (Directory.Exists(path)) Directory.Delete(path, true); @@ -286,14 +294,18 @@ namespace osu.Game.Tests.NonVisual return path; } - private string prepareCustomPath(string suffix = "") + private static string prepareCustomPath() => Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path-{Guid.NewGuid()}"); + + private static void cleanupPath(string path) { - string path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path{suffix}"); - - if (Directory.Exists(path)) - Directory.Delete(path, true); - - return path; + try + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + } + catch + { + } } public class CustomTestHeadlessGameHost : CleanRunHeadlessGameHost @@ -303,7 +315,7 @@ namespace osu.Game.Tests.NonVisual public CustomTestHeadlessGameHost([CallerMemberName] string callingMethodName = @"") : base(callingMethodName: callingMethodName) { - string defaultStorageLocation = getDefaultLocationFor(callingMethodName); + string defaultStorageLocation = getDefaultLocationFor(this); InitialStorage = new DesktopStorage(defaultStorageLocation, this); InitialStorage.DeleteDirectory(string.Empty); diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 55378043e6..8ba3d1a6c7 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -16,7 +16,8 @@ namespace osu.Game.Tests.NonVisual.Filtering { private BeatmapInfo getExampleBeatmap() => new BeatmapInfo { - Ruleset = new RulesetInfo { OnlineID = 5 }, + Ruleset = new RulesetInfo { OnlineID = 0 }, + RulesetID = 0, StarRating = 4.0d, BaseDifficulty = new BeatmapDifficulty { diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 42305ccd81..0c49a18c8f 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -45,8 +45,6 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddRepeatStep("add some users", () => Client.AddUser(new APIUser { Id = id++ }), 5); checkPlayingUserCount(0); - AddAssert("playlist item is available", () => Client.CurrentMatchPlayingItem.Value != null); - changeState(3, MultiplayerUserState.WaitingForLoad); checkPlayingUserCount(3); @@ -64,15 +62,13 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("leave room", () => Client.LeaveRoom()); checkPlayingUserCount(0); - - AddAssert("playlist item is null", () => Client.CurrentMatchPlayingItem.Value == null); } [Test] public void TestPlayingUsersUpdatedOnJoin() { AddStep("leave room", () => Client.LeaveRoom()); - AddUntilStep("wait for room part", () => Client.Room == null); + AddUntilStep("wait for room part", () => !RoomJoined); AddStep("create room initially in gameplay", () => { diff --git a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs index d5fd803986..cd02f15adf 100644 --- a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs +++ b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs @@ -1,9 +1,10 @@ // 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.Game.Configuration; -using osu.Game.Input; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.NonVisual { @@ -11,37 +12,32 @@ namespace osu.Game.Tests.NonVisual public class SessionStaticsTest { private SessionStatics sessionStatics; - private IdleTracker sessionIdleTracker; - [SetUp] - public void SetUp() + [Test] + public void TestSessionStaticsReset() { sessionStatics = new SessionStatics(); - sessionIdleTracker = new GameIdleTracker(1000); sessionStatics.SetValue(Static.LoginOverlayDisplayed, true); sessionStatics.SetValue(Static.MutedAudioNotificationShownOnce, true); sessionStatics.SetValue(Static.LowBatteryNotificationShownOnce, true); sessionStatics.SetValue(Static.LastHoverSoundPlaybackTime, (double?)1d); + sessionStatics.SetValue(Static.SeasonalBackgrounds, new APISeasonalBackgrounds { EndDate = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero) }); - sessionIdleTracker.IsIdle.BindValueChanged(e => - { - if (e.NewValue) - sessionStatics.ResetValues(); - }); - } + Assert.IsFalse(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); + Assert.IsFalse(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); + Assert.IsFalse(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); + Assert.IsFalse(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); + Assert.IsFalse(sessionStatics.GetBindable(Static.SeasonalBackgrounds).IsDefault); - [Test] - [Timeout(2000)] - public void TestSessionStaticsReset() - { - sessionIdleTracker.IsIdle.BindValueChanged(e => - { - Assert.IsTrue(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); - Assert.IsTrue(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); - Assert.IsTrue(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); - Assert.IsTrue(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); - }); + sessionStatics.ResetAfterInactivity(); + + Assert.IsTrue(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); + Assert.IsTrue(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); + Assert.IsTrue(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); + // some statics should not reset despite inactivity. + Assert.IsFalse(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); + Assert.IsFalse(sessionStatics.GetBindable(Static.SeasonalBackgrounds).IsDefault); } } } diff --git a/osu.Game.Tests/NonVisual/TaskChainTest.cs b/osu.Game.Tests/NonVisual/TaskChainTest.cs index d83eaafe20..3678279035 100644 --- a/osu.Game.Tests/NonVisual/TaskChainTest.cs +++ b/osu.Game.Tests/NonVisual/TaskChainTest.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Game.Utils; namespace osu.Game.Tests.NonVisual @@ -42,9 +43,9 @@ namespace osu.Game.Tests.NonVisual await Task.WhenAll(task1.task, task2.task, task3.task); - Assert.That(task1.task.Result, Is.EqualTo(1)); - Assert.That(task2.task.Result, Is.EqualTo(2)); - Assert.That(task3.task.Result, Is.EqualTo(3)); + Assert.That(task1.task.GetResultSafely(), Is.EqualTo(1)); + Assert.That(task2.task.GetResultSafely(), Is.EqualTo(2)); + Assert.That(task3.task.GetResultSafely(), Is.EqualTo(3)); } [Test] @@ -68,9 +69,9 @@ namespace osu.Game.Tests.NonVisual // Wait on both tasks. await Task.WhenAll(task1.task, task3.task); - Assert.That(task1.task.Result, Is.EqualTo(1)); + Assert.That(task1.task.GetResultSafely(), Is.EqualTo(1)); Assert.That(task2.task.IsCompleted, Is.False); - Assert.That(task3.task.Result, Is.EqualTo(2)); + Assert.That(task3.task.GetResultSafely(), Is.EqualTo(2)); } [Test] diff --git a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs new file mode 100644 index 0000000000..855de9b656 --- /dev/null +++ b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs @@ -0,0 +1,90 @@ +// 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.Online.Chat; + +namespace osu.Game.Tests.Online.Chat +{ + [TestFixture] + public class MessageNotifierTest + { + [Test] + public void TestContainsUsernameMidlinePositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test message", "Test")); + } + + [Test] + public void TestContainsUsernameStartOfLinePositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test message", "Test")); + } + + [Test] + public void TestContainsUsernameEndOfLinePositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test", "Test")); + } + + [Test] + public void TestContainsUsernameMidlineNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a testmessage for notifications", "Test")); + } + + [Test] + public void TestContainsUsernameStartOfLineNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("Testmessage", "Test")); + } + + [Test] + public void TestContainsUsernameEndOfLineNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a notificationtest", "Test")); + } + + [Test] + public void TestContainsUsernameBetweenPunctuation() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Hello 'test'-message", "Test")); + } + + [Test] + public void TestContainsUsernameUnicode() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test \u0460\u0460 message", "\u0460\u0460")); + } + + [Test] + public void TestContainsUsernameUnicodeNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test ha\u0460\u0460o message", "\u0460\u0460")); + } + + [Test] + public void TestContainsUsernameSpecialCharactersPositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test [#^-^#] message", "[#^-^#]")); + } + + [Test] + public void TestContainsUsernameSpecialCharactersNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test pad[#^-^#]oru message", "[#^-^#]")); + } + + [Test] + public void TestContainsUsernameAtSign() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("@username hi", "username")); + } + + [Test] + public void TestContainsUsernameColon() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("username: hi", "username")); + } + } +} diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 8378b33b3d..4b160e1d67 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -13,7 +13,6 @@ using osu.Game.Online.Solo; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -94,7 +93,7 @@ namespace osu.Game.Tests.Online [Test] public void TestDeserialiseSubmittableScoreWithEmptyMods() { - var score = new SubmittableScore(new ScoreInfo { Ruleset = new OsuRuleset().RulesetInfo }); + var score = new SubmittableScore(new ScoreInfo()); var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); @@ -106,7 +105,6 @@ namespace osu.Game.Tests.Online { var score = new SubmittableScore(new ScoreInfo { - Ruleset = new OsuRuleset().RulesetInfo, Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2 } } } }); diff --git a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs index 1027b722d1..81475f2fbe 100644 --- a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Online } [Test] - public void TestSerialiseUnionFailsWithSingalR() + public void TestSerialiseUnionFailsWithSignalR() { var state = new TeamVersusUserState(); diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 24824b1e23..a7b431fb6e 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -100,7 +100,7 @@ namespace osu.Game.Tests.Online public void TestTrackerRespectsSoftDeleting() { AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); - AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).Wait()); + AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); AddStep("delete beatmap", () => beatmaps.Delete(beatmaps.QueryBeatmapSet(b => b.OnlineID == testBeatmapSet.OnlineID))); @@ -114,18 +114,23 @@ namespace osu.Game.Tests.Online public void TestTrackerRespectsChecksum() { AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); + addAvailabilityCheckStep("initially locally available", BeatmapAvailability.LocallyAvailable); AddStep("import altered beatmap", () => { - beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).WaitSafely(); }); - addAvailabilityCheckStep("state still not downloaded", BeatmapAvailability.NotDownloaded); + addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); AddStep("recreate tracker", () => Child = availabilityTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } }); addAvailabilityCheckStep("state not downloaded as well", BeatmapAvailability.NotDownloaded); + + AddStep("reimport original beatmap", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely()); + addAvailabilityCheckStep("locally available after re-import", BeatmapAvailability.LocallyAvailable); } private void addAvailabilityCheckStep(string description, Func expected) diff --git a/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs b/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs new file mode 100644 index 0000000000..d33081662d --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Online.Rooms; + +namespace osu.Game.Tests.OnlinePlay +{ + [TestFixture] + public class PlaylistExtensionsTest + { + [Test] + public void TestEmpty() + { + // mostly an extreme edge case, i.e. during room creation. + var items = Array.Empty(); + + Assert.Multiple(() => + { + Assert.That(items.GetHistoricalItems(), Is.Empty); + Assert.That(items.GetCurrentItem(), Is.Null); + Assert.That(items.GetUpcomingItems(), Is.Empty); + }); + } + + [Test] + public void TestPlaylistItemsInOrder() + { + var items = new[] + { + new PlaylistItem { ID = 1, BeatmapID = 1001, PlaylistOrder = 1 }, + new PlaylistItem { ID = 2, BeatmapID = 1002, PlaylistOrder = 2 }, + new PlaylistItem { ID = 3, BeatmapID = 1003, PlaylistOrder = 3 }, + }; + + Assert.Multiple(() => + { + Assert.That(items.GetHistoricalItems(), Is.Empty); + Assert.That(items.GetCurrentItem(), Is.EqualTo(items[0])); + Assert.That(items.GetUpcomingItems(), Is.EquivalentTo(items)); + }); + } + + [Test] + public void TestPlaylistItemsOutOfOrder() + { + var items = new[] + { + new PlaylistItem { ID = 2, BeatmapID = 1002, PlaylistOrder = 2 }, + new PlaylistItem { ID = 1, BeatmapID = 1001, PlaylistOrder = 1 }, + new PlaylistItem { ID = 3, BeatmapID = 1003, PlaylistOrder = 3 }, + }; + + Assert.Multiple(() => + { + Assert.That(items.GetHistoricalItems(), Is.Empty); + Assert.That(items.GetCurrentItem(), Is.EqualTo(items[1])); + Assert.That(items.GetUpcomingItems(), Is.EquivalentTo(new[] { items[1], items[0], items[2] })); + }); + } + + [Test] + public void TestExpiredPlaylistItemsSkipped() + { + var items = new[] + { + new PlaylistItem { ID = 1, BeatmapID = 1001, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 55, 0, TimeSpan.Zero) }, + new PlaylistItem { ID = 2, BeatmapID = 1002, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 53, 0, TimeSpan.Zero) }, + new PlaylistItem { ID = 3, BeatmapID = 1003, PlaylistOrder = 3 }, + }; + + Assert.Multiple(() => + { + Assert.That(items.GetHistoricalItems(), Is.EquivalentTo(new[] { items[1], items[0] })); + Assert.That(items.GetCurrentItem(), Is.EqualTo(items[2])); + Assert.That(items.GetUpcomingItems(), Is.EquivalentTo(new[] { items[2] })); + }); + } + + [Test] + public void TestAllItemsExpired() + { + var items = new[] + { + new PlaylistItem { ID = 1, BeatmapID = 1001, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 55, 0, TimeSpan.Zero) }, + new PlaylistItem { ID = 2, BeatmapID = 1002, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 53, 0, TimeSpan.Zero) }, + new PlaylistItem { ID = 3, BeatmapID = 1002, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 57, 0, TimeSpan.Zero) }, + }; + + Assert.Multiple(() => + { + Assert.That(items.GetHistoricalItems(), Is.EquivalentTo(new[] { items[1], items[0], items[2] })); + // if all items are expired, the last-played item is expected to be returned. + Assert.That(items.GetCurrentItem(), Is.EqualTo(items[2])); + Assert.That(items.GetUpcomingItems(), Is.Empty); + }); + } + } +} diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index 440d5e701f..445394fc77 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using NUnit.Framework; @@ -12,8 +13,12 @@ using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; namespace osu.Game.Tests.Resources { @@ -137,5 +142,63 @@ namespace osu.Game.Tests.Resources } } } + + /// + /// Create a test score model. + /// + /// The ruleset for which the score was set against. + /// + public static ScoreInfo CreateTestScoreInfo(RulesetInfo ruleset = null) => + CreateTestScoreInfo(CreateTestBeatmapSetInfo(1, new[] { ruleset ?? new OsuRuleset().RulesetInfo }).Beatmaps.First()); + + /// + /// Create a test score model. + /// + /// The beatmap for which the score was set against. + /// + public static ScoreInfo CreateTestScoreInfo(BeatmapInfo beatmap) => new ScoreInfo + { + User = new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + BeatmapInfo = beatmap, + Ruleset = beatmap.Ruleset, + RulesetID = beatmap.Ruleset.ID ?? 0, + Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }, + TotalScore = 2845370, + Accuracy = 0.95, + MaxCombo = 999, + Position = 1, + Rank = ScoreRank.S, + Date = DateTimeOffset.Now, + Statistics = new Dictionary + { + [HitResult.Miss] = 1, + [HitResult.Meh] = 50, + [HitResult.Ok] = 100, + [HitResult.Good] = 200, + [HitResult.Great] = 300, + [HitResult.Perfect] = 320, + [HitResult.SmallTickHit] = 50, + [HitResult.SmallTickMiss] = 25, + [HitResult.LargeTickHit] = 100, + [HitResult.LargeTickMiss] = 50, + [HitResult.SmallBonus] = 10, + [HitResult.SmallBonus] = 50 + }, + }; + + private class TestModHardRock : ModHardRock + { + public override double ScoreMultiplier => 1; + } + + private class TestModDoubleTime : ModDoubleTime + { + public override double ScoreMultiplier => 1; + } } } diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index c357fccd27..20439ac969 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; @@ -114,7 +115,7 @@ namespace osu.Game.Tests.Rulesets public Sample Get(string name) => null; - public Task GetAsync(string name) => null; + public Task GetAsync(string name, CancellationToken cancellationToken = default) => null; public Stream GetStream(string name) => null; diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 0dee0f89ea..bbc92b7817 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Scores.IO Combo = 250, User = new APIUser { Username = "Test user" }, Date = DateTimeOffset.Now, - OnlineScoreID = 12345, + OnlineID = 12345, }; var imported = await LoadScoreIntoOsu(osu, toImport); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Scores.IO Assert.AreEqual(toImport.Combo, imported.Combo); Assert.AreEqual(toImport.User.Username, imported.User.Username); Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineScoreID, imported.OnlineScoreID); + Assert.AreEqual(toImport.OnlineID, imported.OnlineID); } finally { @@ -163,12 +163,12 @@ namespace osu.Game.Tests.Scores.IO { var osu = LoadOsuIntoHost(host, true); - await LoadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader()); + await LoadScoreIntoOsu(osu, new ScoreInfo { OnlineID = 2 }, new TestArchiveReader()); var scoreManager = osu.Dependencies.Get(); // Note: A new score reference is used here since the import process mutates the original object to set an ID - Assert.That(scoreManager.IsAvailableLocally(new ScoreInfo { OnlineScoreID = 2 })); + Assert.That(scoreManager.IsAvailableLocally(new ScoreInfo { OnlineID = 2 })); } finally { diff --git a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs index d1374eb6e5..42fcb3acab 100644 --- a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs +++ b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs @@ -44,24 +44,6 @@ namespace osu.Game.Tests.Scores.IO Assert.That(score1, Is.EqualTo(score2)); } - [Test] - public void TestNonMatchingByHash() - { - ScoreInfo score1 = new ScoreInfo { Hash = "a" }; - ScoreInfo score2 = new ScoreInfo { Hash = "b" }; - - Assert.That(score1, Is.Not.EqualTo(score2)); - } - - [Test] - public void TestMatchingByHash() - { - ScoreInfo score1 = new ScoreInfo { Hash = "a" }; - ScoreInfo score2 = new ScoreInfo { Hash = "a" }; - - Assert.That(score1, Is.EqualTo(score2)); - } - [Test] public void TestNonMatchingByNull() { diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index ecc9c92025..3f063264e0 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -8,7 +8,9 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Platform; +using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Skinning; @@ -163,32 +165,109 @@ namespace osu.Game.Tests.Skins.IO assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", osu); }); + [Test] + public Task TestExportThenImportDefaultSkin() => runSkinTest(osu => + { + var skinManager = osu.Dependencies.Get(); + + skinManager.EnsureMutableSkin(); + + MemoryStream exportStream = new MemoryStream(); + + Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; + + skinManager.CurrentSkinInfo.Value.PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + + new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + + Assert.Greater(exportStream.Length, 0); + }); + + var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); + + imported.GetResultSafely().PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreNotEqual(originalSkinId, s.ID); + Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + }); + + return Task.CompletedTask; + }); + + [Test] + public Task TestExportThenImportClassicSkin() => runSkinTest(osu => + { + var skinManager = osu.Dependencies.Get(); + + skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + + skinManager.EnsureMutableSkin(); + + MemoryStream exportStream = new MemoryStream(); + + Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; + + skinManager.CurrentSkinInfo.Value.PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); + + new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + + Assert.Greater(exportStream.Length, 0); + }); + + var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); + + imported.GetResultSafely().PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreNotEqual(originalSkinId, s.ID); + Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); + }); + + return Task.CompletedTask; + }); + #endregion - private void assertCorrectMetadata(SkinInfo import1, string name, string creator, OsuGameBase osu) + private void assertCorrectMetadata(ILive import1, string name, string creator, OsuGameBase osu) { - Assert.That(import1.Name, Is.EqualTo(name)); - Assert.That(import1.Creator, Is.EqualTo(creator)); + import1.PerformRead(i => + { + Assert.That(i.Name, Is.EqualTo(name)); + Assert.That(i.Creator, Is.EqualTo(creator)); - // for extra safety let's reconstruct the skin, reading from the skin.ini. - var instance = import1.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager))); + // for extra safety let's reconstruct the skin, reading from the skin.ini. + var instance = i.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager))); - Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name)); - Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator)); + Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name)); + Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator)); + }); } - private void assertImportedBoth(SkinInfo import1, SkinInfo import2) + private void assertImportedBoth(ILive import1, ILive import2) { - Assert.That(import2.ID, Is.Not.EqualTo(import1.ID)); - Assert.That(import2.Hash, Is.Not.EqualTo(import1.Hash)); - Assert.That(import2.Files.Select(f => f.FileInfoID), Is.Not.EquivalentTo(import1.Files.Select(f => f.FileInfoID))); + import1.PerformRead(i1 => import2.PerformRead(i2 => + { + Assert.That(i2.ID, Is.Not.EqualTo(i1.ID)); + Assert.That(i2.Hash, Is.Not.EqualTo(i1.Hash)); + Assert.That(i2.Files.First(), Is.Not.EqualTo(i1.Files.First())); + })); } - private void assertImportedOnce(SkinInfo import1, SkinInfo import2) + private void assertImportedOnce(ILive import1, ILive import2) { - Assert.That(import2.ID, Is.EqualTo(import1.ID)); - Assert.That(import2.Hash, Is.EqualTo(import1.Hash)); - Assert.That(import2.Files.Select(f => f.FileInfoID), Is.EquivalentTo(import1.Files.Select(f => f.FileInfoID))); + import1.PerformRead(i1 => import2.PerformRead(i2 => + { + Assert.That(i2.ID, Is.EqualTo(i1.ID)); + Assert.That(i2.Hash, Is.EqualTo(i1.Hash)); + Assert.That(i2.Files.First(), Is.EqualTo(i1.Files.First())); + })); } private MemoryStream createEmptyOsk() @@ -241,7 +320,7 @@ namespace osu.Game.Tests.Skins.IO private async Task runSkinTest(Func action, [CallerMemberName] string callingMethodName = @"") { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(callingMethodName)) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(callingMethodName: callingMethodName)) { try { @@ -255,10 +334,10 @@ namespace osu.Game.Tests.Skins.IO } } - private async Task loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) + private async Task> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) { var skinManager = osu.Dependencies.Get(); - return (await skinManager.Import(archive)).Value; + return await skinManager.Import(archive); } } } diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs index 1d8b754837..c20ab84a68 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; +using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -24,7 +25,7 @@ namespace osu.Game.Tests.Skins [BackgroundDependencyLoader] private void load() { - var imported = beatmaps.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-beatmap.osz"))).Result; + var imported = beatmaps.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-beatmap.osz"))).GetResultSafely(); beatmap = beatmaps.GetWorkingBeatmap(imported.Value.Beatmaps[0]); beatmap.LoadTrack(); } diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs index 10f1ab31df..0271198049 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.IO.Archives; @@ -23,8 +24,8 @@ namespace osu.Game.Tests.Skins [BackgroundDependencyLoader] private void load() { - var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result; - skin = skins.GetSkin(imported.Value); + var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).GetResultSafely(); + skin = imported.PerformRead(skinInfo => skins.GetSkin(skinInfo)); } [Test] diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index ec16578b71..884e74346b 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -5,15 +5,26 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Background { @@ -21,8 +32,7 @@ namespace osu.Game.Tests.Visual.Background public class TestSceneBackgroundScreenDefault : OsuTestScene { private BackgroundScreenStack stack; - private BackgroundScreenDefault screen; - + private TestBackgroundScreenDefault screen; private Graphics.Backgrounds.Background getCurrentBackground() => screen.ChildrenOfType().FirstOrDefault(); [Resolved] @@ -35,10 +45,136 @@ namespace osu.Game.Tests.Visual.Background public void SetUpSteps() { AddStep("create background stack", () => Child = stack = new BackgroundScreenStack()); - AddStep("push default screen", () => stack.Push(screen = new BackgroundScreenDefault(false))); + AddStep("push default screen", () => stack.Push(screen = new TestBackgroundScreenDefault())); AddUntilStep("wait for screen to load", () => screen.IsCurrentScreen()); } + [Test] + public void TestBeatmapBackgroundTracksBeatmap() + { + setSupporter(true); + setSourceMode(BackgroundSource.Beatmap); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + + Graphics.Backgrounds.Background last = null; + + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackground)); + AddStep("store background", () => last = getCurrentBackground()); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + + AddUntilStep("wait for beatmap background to change", () => screen.CheckLastLoadChange() == true); + + AddUntilStep("background is new beatmap background", () => last != getCurrentBackground()); + AddStep("store background", () => last = getCurrentBackground()); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + + AddUntilStep("wait for beatmap background to change", () => screen.CheckLastLoadChange() == true); + AddUntilStep("background is new beatmap background", () => last != getCurrentBackground()); + } + + [Test] + public void TestBeatmapBackgroundTracksBeatmapWhenSuspended() + { + setSupporter(true); + setSourceMode(BackgroundSource.Beatmap); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackground)); + + BackgroundScreenBeatmap nestedScreen = null; + + // of note, this needs to be a type that doesn't match BackgroundScreenDefault else it is silently not pushed by the background stack. + AddStep("push new background to stack", () => stack.Push(nestedScreen = new BackgroundScreenBeatmap(Beatmap.Value))); + AddUntilStep("wait for screen to load", () => nestedScreen.IsLoaded && nestedScreen.IsCurrentScreen()); + AddUntilStep("previous background hidden", () => !screen.IsAlive); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("pop screen back to top level", () => screen.MakeCurrent()); + + AddAssert("top level background changed", () => screen.CheckLastLoadChange() == true); + } + + [Test] + public void TestBeatmapBackgroundIgnoresNoChangeWhenSuspended() + { + BackgroundScreenBeatmap nestedScreen = null; + WorkingBeatmap originalWorking = null; + + setSupporter(true); + setSourceMode(BackgroundSource.Beatmap); + + AddStep("change beatmap", () => originalWorking = Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackground)); + + // of note, this needs to be a type that doesn't match BackgroundScreenDefault else it is silently not pushed by the background stack. + AddStep("push new background to stack", () => stack.Push(nestedScreen = new BackgroundScreenBeatmap(Beatmap.Value))); + AddUntilStep("wait for screen to load", () => nestedScreen.IsLoaded && nestedScreen.IsCurrentScreen()); + + // we're testing a case where scheduling may be used to avoid issues, so ensure the scheduler is no longer running. + AddUntilStep("wait for top level not alive", () => !screen.IsAlive); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddStep("change beatmap back", () => Beatmap.Value = originalWorking); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("pop screen back to top level", () => screen.MakeCurrent()); + + AddStep("top level screen is current", () => screen.IsCurrentScreen()); + AddAssert("top level background reused existing", () => screen.CheckLastLoadChange() == false); + } + + [Test] + public void TestBeatmapBackgroundWithStoryboardClockAlwaysUsesCurrentTrack() + { + BackgroundScreenBeatmap nestedScreen = null; + WorkingBeatmap originalWorking = null; + + setSupporter(true); + setSourceMode(BackgroundSource.BeatmapWithStoryboard); + + AddStep("change beatmap", () => originalWorking = Beatmap.Value = createTestWorkingBeatmapWithStoryboard()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackgroundWithStoryboard)); + + AddStep("start music", () => MusicController.Play()); + AddUntilStep("storyboard clock running", () => screen.ChildrenOfType().SingleOrDefault()?.Clock.IsRunning == true); + + // of note, this needs to be a type that doesn't match BackgroundScreenDefault else it is silently not pushed by the background stack. + AddStep("push new background to stack", () => stack.Push(nestedScreen = new BackgroundScreenBeatmap(Beatmap.Value))); + AddUntilStep("wait for screen to load", () => nestedScreen.IsLoaded && nestedScreen.IsCurrentScreen()); + + // we're testing a case where scheduling may be used to avoid issues, so ensure the scheduler is no longer running. + AddUntilStep("wait for top level not alive", () => !screen.IsAlive); + + AddStep("stop music", () => MusicController.Stop()); + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithStoryboard()); + AddStep("change beatmap back", () => Beatmap.Value = originalWorking); + AddStep("restart music", () => MusicController.Play()); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("pop screen back to top level", () => screen.MakeCurrent()); + + AddStep("top level screen is current", () => screen.IsCurrentScreen()); + AddAssert("top level background reused existing", () => screen.CheckLastLoadChange() == false); + AddUntilStep("storyboard clock running", () => screen.ChildrenOfType().Single().Clock.IsRunning); + + AddStep("stop music", () => MusicController.Stop()); + AddStep("restore default beatmap", () => Beatmap.SetDefault()); + } + [Test] public void TestBackgroundTypeSwitch() { @@ -77,36 +213,24 @@ namespace osu.Game.Tests.Visual.Background [TestCase(BackgroundSource.Skin, typeof(SkinBackground))] public void TestBackgroundDoesntReloadOnNoChange(BackgroundSource source, Type backgroundType) { - Graphics.Backgrounds.Background last = null; - setSourceMode(source); setSupporter(true); if (source == BackgroundSource.Skin) setCustomSkin(); - AddUntilStep("wait for beatmap background to be loaded", () => (last = getCurrentBackground())?.GetType() == backgroundType); + AddUntilStep("wait for beatmap background to be loaded", () => (getCurrentBackground())?.GetType() == backgroundType); AddAssert("next doesn't load new background", () => screen.Next() == false); - - // doesn't really need to be checked but might as well. - AddWaitStep("wait a bit", 5); - AddUntilStep("ensure same background instance", () => last == getCurrentBackground()); } [Test] public void TestBackgroundCyclingOnDefaultSkin([Values] bool supporter) { - Graphics.Backgrounds.Background last = null; - setSourceMode(BackgroundSource.Skin); setSupporter(supporter); setDefaultSkin(); - AddUntilStep("wait for beatmap background to be loaded", () => (last = getCurrentBackground())?.GetType() == typeof(Graphics.Backgrounds.Background)); + AddUntilStep("wait for beatmap background to be loaded", () => (getCurrentBackground())?.GetType() == typeof(Graphics.Backgrounds.Background)); AddAssert("next cycles background", () => screen.Next()); - - // doesn't really need to be checked but might as well. - AddWaitStep("wait a bit", 5); - AddUntilStep("ensure different background instance", () => last != getCurrentBackground()); } private void setSourceMode(BackgroundSource source) => @@ -119,10 +243,92 @@ namespace osu.Game.Tests.Visual.Background Id = API.LocalUser.Value.Id + 1, }); + private WorkingBeatmap createTestWorkingBeatmapWithUniqueBackground() => new UniqueBackgroundTestWorkingBeatmap(Audio); + private WorkingBeatmap createTestWorkingBeatmapWithStoryboard() => new TestWorkingBeatmapWithStoryboard(Audio); + + private class TestBackgroundScreenDefault : BackgroundScreenDefault + { + private bool? lastLoadTriggerCausedChange; + + public TestBackgroundScreenDefault() + : base(false) + { + } + + public override bool Next() + { + bool didChange = base.Next(); + lastLoadTriggerCausedChange = didChange; + return didChange; + } + + public bool? CheckLastLoadChange() + { + bool? lastChange = lastLoadTriggerCausedChange; + lastLoadTriggerCausedChange = null; + return lastChange; + } + } + + private class UniqueBackgroundTestWorkingBeatmap : TestWorkingBeatmap + { + public UniqueBackgroundTestWorkingBeatmap(AudioManager audioManager) + : base(new Beatmap(), null, audioManager) + { + } + + protected override Texture GetBackground() => new Texture(1, 1); + } + + private class TestWorkingBeatmapWithStoryboard : TestWorkingBeatmap + { + public TestWorkingBeatmapWithStoryboard(AudioManager audioManager) + : base(new Beatmap(), createStoryboard(), audioManager) + { + } + + protected override Track GetBeatmapTrack() => new TrackVirtual(100000); + + private static Storyboard createStoryboard() + { + var storyboard = new Storyboard(); + storyboard.Layers.Last().Add(new TestStoryboardElement()); + return storyboard; + } + + private class TestStoryboardElement : IStoryboardElementWithDuration + { + public string Path => string.Empty; + public bool IsDrawable => true; + public double StartTime => double.MinValue; + public double EndTime => double.MaxValue; + + public Drawable CreateDrawable() => new DrawableTestStoryboardElement(); + } + + private class DrawableTestStoryboardElement : OsuSpriteText + { + public override bool RemoveWhenNotAlive => false; + + public DrawableTestStoryboardElement() + { + Anchor = Origin = Anchor.Centre; + Font = OsuFont.Default.With(size: 32); + Text = "(not started)"; + } + + protected override void Update() + { + base.Update(); + Text = Time.Current.ToString("N2"); + } + } + } + private void setCustomSkin() { // feign a skin switch. this doesn't do anything except force CurrentSkin to become a LegacySkin. - AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo { ID = 5 }); + AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo().ToLiveUnmanaged()); } private void setDefaultSkin() => AddStep("set default skin", () => skins.CurrentSkinInfo.SetDefault()); diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 194341d1ab..5b2cf877ba 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -7,18 +7,19 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Input.States; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Utils; 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.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; @@ -28,7 +29,6 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; -using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; @@ -36,7 +36,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Background { [TestFixture] - public class TestSceneUserDimBackgrounds : OsuManualInputManagerTestScene + public class TestSceneUserDimBackgrounds : ScreenTestScene { private DummySongSelect songSelect; private TestPlayerLoader playerLoader; @@ -51,19 +51,17 @@ namespace osu.Game.Tests.Visual.Background Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); - manager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + manager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); Beatmap.SetDefault(); } - [SetUp] - public virtual void SetUp() => Schedule(() => + public override void SetUpSteps() { - var stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; - Child = stack; + base.SetUpSteps(); - stack.Push(songSelect = new DummySongSelect()); - }); + AddStep("push song select", () => Stack.Push(songSelect = new DummySongSelect())); + } /// /// User settings should always be ignored on song select screen. @@ -229,12 +227,7 @@ namespace osu.Game.Tests.Visual.Background FadeAccessibleResults results = null; - AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(new ScoreInfo - { - User = new APIUser { Username = "osu!" }, - BeatmapInfo = new TestBeatmap(Ruleset.Value).BeatmapInfo, - Ruleset = Ruleset.Value, - }))); + AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(TestResources.CreateTestScoreInfo()))); AddUntilStep("Wait for results is current", () => results.IsCurrentScreen()); @@ -329,7 +322,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundUndimmed() => background.CurrentColour == Color4.White; - public bool IsUserBlurApplied() => background.CurrentBlur == new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR); + public bool IsUserBlurApplied() => Precision.AlmostEquals(background.CurrentBlur, new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR), 0.1f); public bool IsUserBlurDisabled() => background.CurrentBlur == new Vector2(0); @@ -337,9 +330,9 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundVisible() => background.CurrentAlpha == 1; - public bool IsBackgroundBlur() => background.CurrentBlur == new Vector2(BACKGROUND_BLUR); + public bool IsBackgroundBlur() => Precision.AlmostEquals(background.CurrentBlur, new Vector2(BACKGROUND_BLUR), 0.1f); - public bool CheckBackgroundBlur(Vector2 expected) => background.CurrentBlur == expected; + public bool CheckBackgroundBlur(Vector2 expected) => Precision.AlmostEquals(background.CurrentBlur, expected, 0.1f); /// /// Make sure every time a screen gets pushed, the background doesn't get replaced diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs index 5effc1f215..7b5e1f4ec7 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs @@ -11,17 +11,18 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables.Cards; +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 APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCard : OsuTestScene + public class TestSceneBeatmapCard : OsuManualInputManagerTestScene { /// /// All cards on this scene use a common online ID to ensure that map download, preview tracks, etc. can be tested manually with online sources. @@ -95,6 +96,7 @@ namespace osu.Game.Tests.Visual.Beatmaps var longName = CreateAPIBeatmapSet(Ruleset.Value); longName.Title = longName.TitleUnicode = "this track has an incredibly and implausibly long title"; longName.Artist = longName.ArtistUnicode = "and this artist! who would have thunk it. it's really such a long name."; + longName.Source = "wow. even the source field has an impossibly long string in it. this really takes the cake, doesn't it?"; longName.HasExplicitContent = true; longName.TrackId = 444; @@ -227,7 +229,7 @@ namespace osu.Game.Tests.Visual.Beatmaps new BasicScrollContainer { RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + Child = new ReverseChildIDFillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -248,6 +250,41 @@ namespace osu.Game.Tests.Visual.Beatmaps } [Test] - public void TestNormal() => createTestCase(beatmapSetInfo => new BeatmapCard(beatmapSetInfo)); + public void TestNormal() + { + createTestCase(beatmapSetInfo => new BeatmapCardNormal(beatmapSetInfo)); + } + + [Test] + public void TestExtra() + { + createTestCase(beatmapSetInfo => new BeatmapCardExtra(beatmapSetInfo)); + } + + [Test] + public void TestHoverState() + { + AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, s => new BeatmapCardNormal(s))); + + AddStep("Hover card", () => InputManager.MoveMouseTo(firstCard())); + AddWaitStep("wait for potential state change", 5); + AddAssert("card is not expanded", () => !firstCard().Expanded.Value); + + AddStep("Hover spectrum display", () => InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single())); + AddUntilStep("card is expanded", () => firstCard().Expanded.Value); + + AddStep("Hover difficulty content", () => InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single())); + AddWaitStep("wait for potential state change", 5); + AddAssert("card is still expanded", () => firstCard().Expanded.Value); + + AddStep("Hover main content again", () => InputManager.MoveMouseTo(firstCard())); + AddWaitStep("wait for potential state change", 5); + AddAssert("card is still expanded", () => firstCard().Expanded.Value); + + AddStep("Hover away", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("card is not expanded", () => !firstCard().Expanded.Value); + + BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); + } } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs index aec75884d6..e6fb4372ff 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.Drawables.Cards; -using osu.Game.Graphics; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -18,7 +17,7 @@ namespace osu.Game.Tests.Visual.Beatmaps private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { var beatmapSet = new APIBeatmapSet { diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 28218ea220..d2b0f7324b 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -4,6 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -38,7 +39,7 @@ namespace osu.Game.Tests.Visual.Collections Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, Resources, host, Beatmap.Default)); - beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); base.Content.AddRange(new Drawable[] { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs index c81a1abfbc..516305079b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs @@ -5,11 +5,13 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; using osu.Game.Tests.Beatmaps.IO; @@ -31,7 +33,7 @@ namespace osu.Game.Tests.Visual.Editing public override void SetUpSteps() { - AddStep("import test beatmap", () => importedBeatmapSet = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result); + AddStep("import test beatmap", () => importedBeatmapSet = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).GetResultSafely()); base.SetUpSteps(); } @@ -89,6 +91,7 @@ namespace osu.Game.Tests.Visual.Editing confirmEditingBeatmap(() => targetDifficulty); AddAssert("no objects selected", () => !EditorBeatmap.SelectedHitObjects.Any()); + AddUntilStep("wait for drawable ruleset", () => Editor.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); AddStep("paste object", () => Editor.Paste()); if (sameRuleset) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 92c8131568..db20d3c7ba 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Editing public void TestCreateNewBeatmap() { AddStep("save beatmap", () => Editor.Save()); - AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.ID > 0); + AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.IsManaged); AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == false); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index 3aff74a0a8..e41f8372b4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -4,6 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; @@ -85,11 +86,17 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + Slider slider = null; + AddStep("retrieve slider", () => slider = (Slider)EditorBeatmap.HitObjects.Single()); AddAssert("path matches", () => { - var path = ((Slider)EditorBeatmap.HitObjects.Single()).Path; + var path = slider.Path; return path.ControlPoints.Count == 2 && path.ControlPoints.SequenceEqual(addedObject.Path.ControlPoints); }); + + // see `HitObject.control_point_leniency`. + AddAssert("sample control point has correct time", () => Precision.AlmostEquals(slider.SampleControlPoint.Time, slider.GetEndTime(), 1)); + AddAssert("difficulty control point has correct time", () => slider.DifficultyControlPoint.Time == slider.StartTime); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 160af47a6d..6d48ef3ba7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -5,10 +5,12 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; @@ -37,13 +39,14 @@ namespace osu.Game.Tests.Visual.Editing public override void SetUpSteps() { - AddStep("import test beatmap", () => importedBeatmapSet = ImportBeatmapTest.LoadOszIntoOsu(game).Result); + AddStep("import test beatmap", () => importedBeatmapSet = ImportBeatmapTest.LoadOszIntoOsu(game).GetResultSafely()); base.SetUpSteps(); } protected override void LoadEditor() { Beatmap.Value = beatmaps.GetWorkingBeatmap(importedBeatmapSet.Beatmaps.First(b => b.RulesetID == 0)); + SelectedMods.Value = new[] { new ModCinema() }; base.LoadEditor(); } @@ -67,6 +70,7 @@ namespace osu.Game.Tests.Visual.Editing var background = this.ChildrenOfType().Single(); return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; }); + AddAssert("no mods selected", () => SelectedMods.Value.Count == 0); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index c5ab3974a4..e10ef57a25 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected OsuConfigManager Config { get; private set; } [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + private void load() { Dependencies.Cache(Config = new OsuConfigManager(LocalStorage)); Config.GetBindable(OsuSetting.DimLevel).Value = 1.0; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index 7398527f57..c5f56cae9e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -12,6 +12,7 @@ using osu.Framework.Testing; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -41,7 +42,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestEmptyLegacyBeatmapSkinFallsBack() { - CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null)); + CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null)); AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value)); } @@ -52,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("setup skins", () => { - skinManager.CurrentSkinInfo.Value = gameCurrentSkin; + skinManager.CurrentSkinInfo.Value = gameCurrentSkin.ToLiveUnmanaged(); currentBeatmapSkin = getBeatmapSkin(); }); }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs index 745932315c..fa27e1abdd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs @@ -26,6 +26,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("total number of results == 1", () => { var score = new ScoreInfo(); + ((FailPlayer)Player).ScoreProcessor.PopulateScore(score); return score.Statistics.Values.Sum() == 1; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index f5f17a0bc1..e03c8d7561 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -85,11 +85,12 @@ namespace osu.Game.Tests.Visual.Gameplay loopGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; - target.Alpha.Add(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); + double targetTime = addEventToLoop ? 20000 : 0; + target.Alpha.Add(Easing.None, targetTime + firstStoryboardEvent, targetTime + firstStoryboardEvent + 500, 0, 1); // these should be ignored due to being in the future. sprite.TimelineGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); - loopGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); + loopGroup.Alpha.Add(Easing.None, 38000, 40000, 0, 1); storyboard.GetLayer("Background").Add(sprite); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 06eaa726c9..958d617d63 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -251,7 +251,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMutedNotificationMuteButton() { - addVolumeSteps("mute button", () => volumeOverlay.IsMuted.Value = true, () => !volumeOverlay.IsMuted.Value); + addVolumeSteps("mute button", () => + { + // Importantly, in the case the volume is muted but the user has a volume level set, it should be retained. + audioManager.VolumeTrack.Value = 0.5f; + volumeOverlay.IsMuted.Value = true; + }, () => !volumeOverlay.IsMuted.Value && audioManager.VolumeTrack.Value == 0.5f); } /// diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index 324a132120..cf5aadde6d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Screens; @@ -36,7 +37,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool HasCustomSteps => true; - protected override TestPlayer CreatePlayer(Ruleset ruleset) => new NonImportingPlayer(false); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new FakeImportingPlayer(false); + + protected new FakeImportingPlayer Player => (FakeImportingPlayer)base.Player; protected override Ruleset CreatePlayerRuleset() => createCustomRuleset?.Invoke() ?? new OsuRuleset(); @@ -54,7 +57,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNoSubmissionOnResultsWithNoToken() { - prepareTokenResponse(false); + prepareTestAPI(false); createPlayerTest(); @@ -74,7 +77,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSubmissionOnResults() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(); @@ -93,7 +96,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSubmissionForDifferentRuleset() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(createRuleset: () => new TaikoRuleset()); @@ -113,7 +116,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSubmissionForConvertedBeatmap() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(createRuleset: () => new ManiaRuleset(), createBeatmap: _ => createTestBeatmap(new OsuRuleset().RulesetInfo)); @@ -133,7 +136,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNoSubmissionOnExitWithNoToken() { - prepareTokenResponse(false); + prepareTestAPI(false); createPlayerTest(); @@ -150,7 +153,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNoSubmissionOnEmptyFail() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(true); @@ -165,7 +168,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSubmissionOnFail() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(true); @@ -182,7 +185,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNoSubmissionOnEmptyExit() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(); @@ -195,7 +198,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSubmissionOnExit() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(); @@ -207,10 +210,29 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure failing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == false); } + [Test] + public void TestSubmissionOnExitDuringImport() + { + prepareTestAPI(true); + + createPlayerTest(); + AddStep("block imports", () => Player.AllowImportCompletion.Wait()); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + addFakeHit(); + + AddUntilStep("wait for import to start", () => Player.ScoreImportStarted); + + AddStep("exit", () => Player.Exit()); + AddStep("allow import to proceed", () => Player.AllowImportCompletion.Release(1)); + AddUntilStep("ensure submission", () => Player.SubmittedScore != null && Player.ImportedScore != null); + } + [Test] public void TestNoSubmissionOnLocalBeatmap() { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(false, r => { @@ -231,7 +253,7 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(10)] public void TestNoSubmissionOnCustomRuleset(int? rulesetId) { - prepareTokenResponse(true); + prepareTestAPI(true); createPlayerTest(false, createRuleset: () => new OsuRuleset { RulesetInfo = { OnlineID = rulesetId ?? -1 } }); @@ -253,7 +275,7 @@ namespace osu.Game.Tests.Visual.Gameplay })); } - private void prepareTokenResponse(bool validToken) + private void prepareTestAPI(bool validToken) { AddStep("Prepare test API", () => { @@ -267,6 +289,31 @@ namespace osu.Game.Tests.Visual.Gameplay else tokenRequest.TriggerFailure(new APIException("something went wrong!", null)); return true; + + case SubmitSoloScoreRequest submissionRequest: + if (validToken) + { + var requestScore = submissionRequest.Score; + + submissionRequest.TriggerSuccess(new MultiplayerScore + { + ID = 1234, + User = dummyAPI.LocalUser.Value, + Rank = requestScore.Rank, + TotalScore = requestScore.TotalScore, + Accuracy = requestScore.Accuracy, + MaxCombo = requestScore.MaxCombo, + Mods = requestScore.Mods, + Statistics = requestScore.Statistics, + Passed = requestScore.Passed, + EndedAt = DateTimeOffset.Now, + Position = 1 + }); + + return true; + } + + break; } return false; @@ -288,15 +335,26 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private class NonImportingPlayer : TestPlayer + protected class FakeImportingPlayer : TestPlayer { - public NonImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + public bool ScoreImportStarted { get; set; } + public SemaphoreSlim AllowImportCompletion { get; } + public Score ImportedScore { get; private set; } + + public FakeImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) : base(allowPause, showResults, pauseOnFocusLost) { + AllowImportCompletion = new SemaphoreSlim(1); } - protected override Task ImportScore(Score score) + protected override async Task ImportScore(Score score) { + ScoreImportStarted = true; + + await AllowImportCompletion.WaitAsync().ConfigureAwait(false); + + ImportedScore = score; + // It was discovered that Score members could sometimes be half-populated. // In particular, the RulesetID property could be set to 0 even on non-osu! maps. // We want to test that the state of that property is consistent in this test. @@ -311,8 +369,7 @@ namespace osu.Game.Tests.Visual.Gameplay // In the above instance, if a ScoreInfo with Ruleset = {mania} and RulesetID = 0 is attached to an EF context, // RulesetID WILL BE SILENTLY SET TO THE CORRECT VALUE of 3. // - // For the above reasons, importing is disabled in this test. - return Task.CompletedTask; + // For the above reasons, actual importing is disabled in this test. } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index f47fae33ca..3168c4b94e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -9,6 +9,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Graphics.UserInterface; @@ -135,7 +136,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); - AddStep("import score", () => imported = scoreManager.Import(getScoreInfo(true)).Result); + AddStep("import score", () => imported = scoreManager.Import(getScoreInfo(true)).GetResultSafely()); AddUntilStep("state is available", () => downloadButton.State.Value == DownloadState.LocallyAvailable); @@ -164,7 +165,7 @@ namespace osu.Game.Tests.Visual.Gameplay private ScoreInfo getScoreInfo(bool replayAvailable) { - return new APIScoreInfo + return new APIScore { OnlineID = 2553163309, RulesetID = 0, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index dcc193669b..e6361a15d7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -43,83 +43,88 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); - [SetUp] - public void SetUp() => Schedule(() => + [SetUpSteps] + public void SetUpSteps() { - replay = new Replay(); + AddStep("Reset recorder state", cleanUpState); - Add(new GridContainer + AddStep("Setup containers", () => { - RelativeSizeAxes = Axes.Both, - Content = new[] + replay = new Replay(); + + Add(new GridContainer { - new Drawable[] + RelativeSizeAxes = Axes.Both, + Content = new[] { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + new Drawable[] { - Recorder = recorder = new TestReplayRecorder(new Score + recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - Replay = replay, - ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } - }) - { - ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + Recorder = recorder = new TestReplayRecorder(new Score { - new Box + Replay = replay, + ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } + }) + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - 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(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + 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[] { - ReplayInputHandler = new TestFramedReplayInputHandler(replay) + playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + ReplayInputHandler = new TestFramedReplayInputHandler(replay) { - new Box + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Colour = Color4.DarkBlue, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Playback", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Playback", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } } } - } + }); }); - }); + } [Test] public void TestBasic() @@ -184,7 +189,14 @@ namespace osu.Game.Tests.Visual.Gameplay [TearDownSteps] public void TearDown() { - AddStep("stop recorder", () => recorder.Expire()); + AddStep("stop recorder", cleanUpState); + } + + private void cleanUpState() + { + // Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`. + recorder?.RemoveAndDisposeImmediately(); + recorder = null; } public class TestFramedReplayInputHandler : FramedReplayInputHandler diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs deleted file mode 100644 index 3f7155f1e2..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs +++ /dev/null @@ -1,228 +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 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.Framework.Input.StateChanges; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Sprites; -using osu.Game.Replays; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.UI; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Tests.Visual.UserInterface; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public class TestSceneReplayRecording : OsuTestScene - { - private readonly TestRulesetInputManager playbackManager; - - private readonly TestRulesetInputManager recordingManager; - - [Cached] - private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); - - public TestSceneReplayRecording() - { - Replay replay = new Replay(); - - Add(new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - Recorder = new TestReplayRecorder(new Score - { - Replay = replay, - ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } - }) - { - ScreenSpaceToGamefield = pos => recordingManager?.ToLocalSpace(pos) ?? Vector2.Zero, - }, - 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 TestConsumer() - } - }, - } - }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - ReplayInputHandler = new TestFramedReplayInputHandler(replay) - { - GamefieldToScreenSpace = pos => playbackManager?.ToScreenSpace(pos) ?? Vector2.Zero, - }, - 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 TestConsumer() - } - }, - } - } - } - }); - } - - protected override void Update() - { - base.Update(); - - playbackManager.ReplayInputHandler.SetFrameFromTime(Time.Current - 500); - } - } - - public class TestFramedReplayInputHandler : FramedReplayInputHandler - { - public TestFramedReplayInputHandler(Replay replay) - : base(replay) - { - } - - public override void CollectPendingInputs(List inputs) - { - inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); - inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); - } - } - - public class TestConsumer : CompositeDrawable, IKeyBindingHandler - { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); - - private readonly Box box; - - public TestConsumer() - { - Size = new Vector2(30); - - Origin = Anchor.Centre; - - InternalChildren = new Drawable[] - { - box = new Box - { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - }, - }; - } - - protected override bool OnMouseMove(MouseMoveEvent e) - { - Position = e.MousePosition; - return base.OnMouseMove(e); - } - - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat) - return false; - - box.Colour = Color4.White; - return true; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - box.Colour = Color4.Black; - } - } - - public class TestRulesetInputManager : RulesetInputManager - { - public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) - : base(ruleset, variant, unique) - { - } - - protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) - => new TestKeyBindingContainer(); - - internal class TestKeyBindingContainer : KeyBindingContainer - { - public override IEnumerable DefaultKeyBindings => new[] - { - new KeyBinding(InputKey.MouseLeft, TestAction.Down), - }; - } - } - - public class TestReplayFrame : ReplayFrame - { - public Vector2 Position; - - public List Actions = new List(); - - public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) - : base(time) - { - Position = position; - Actions.AddRange(actions); - } - } - - public enum TestAction - { - Down, - } - - internal class TestReplayRecorder : ReplayRecorder - { - public TestReplayRecorder(Score target) - : base(target) - { - } - - protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) => - new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index a0b27755b7..a0602e21b9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -2,12 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Skinning; using osu.Game.Skinning.Editor; namespace osu.Game.Tests.Visual.Gameplay @@ -16,9 +14,6 @@ namespace osu.Game.Tests.Visual.Gameplay { private SkinEditor skinEditor; - [Resolved] - private SkinManager skinManager { get; set; } - protected override bool Autoplay => true; [SetUpSteps] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs index 1c5a05dd1d..bd1fe050af 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; @@ -24,5 +25,17 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("reset combo", () => scoreProcessor.Combo.Value = 0); } + + [Test] + public void TestLegacyComboCounterHiddenByRulesetImplementation() + { + AddToggleStep("toggle legacy hidden by ruleset", visible => + { + foreach (var legacyCounter in this.ChildrenOfType()) + legacyCounter.HiddenByRulesetImplementation = visible; + }); + + AddRepeatStep("increase combo", () => scoreProcessor.Combo.Value++, 10); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 723e35ed55..3074a91dc6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -10,7 +10,6 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -36,9 +35,6 @@ namespace osu.Game.Tests.Visual.Gameplay private Drawable hideTarget => hudOverlay.KeyCounter; private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); - [Resolved] - private OsuConfigManager config { get; set; } - [Test] public void TestComboCounterIncrementing() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 9fadbe02bd..242eca0bbc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -4,6 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; @@ -60,7 +61,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("import beatmap", () => { - importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; + importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).GetResultSafely(); importedBeatmapId = importedBeatmap.Beatmaps.First(b => b.RulesetID == 0).OnlineID ?? -1; }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 5fbccd54c8..f7e9a1fe16 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Gameplay private TestReplayRecorder recorder; - private readonly ManualClock manualClock = new ManualClock(); + private ManualClock manualClock; private OsuSpriteText latencyDisplay; @@ -66,113 +66,121 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); - [SetUp] - public void SetUp() => Schedule(() => + [SetUpSteps] + public void SetUpSteps() { - replay = new Replay(); + AddStep("Reset recorder state", cleanUpState); - users.BindTo(spectatorClient.PlayingUsers); - users.BindCollectionChanged((obj, args) => + AddStep("Setup containers", () => { - switch (args.Action) + replay = new Replay(); + manualClock = new ManualClock(); + + spectatorClient.OnNewFrames += onNewFrames; + + users.BindTo(spectatorClient.PlayingUsers); + users.BindCollectionChanged((obj, args) => { - case NotifyCollectionChangedAction.Add: - Debug.Assert(args.NewItems != null); - - foreach (int user in args.NewItems) - { - if (user == api.LocalUser.Value.Id) - spectatorClient.WatchUser(user); - } - - break; - - case NotifyCollectionChangedAction.Remove: - Debug.Assert(args.OldItems != null); - - foreach (int user in args.OldItems) - { - if (user == api.LocalUser.Value.Id) - spectatorClient.StopWatchingUser(user); - } - - break; - } - }, true); - - spectatorClient.OnNewFrames += onNewFrames; - - Add(new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] + switch (args.Action) { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + + foreach (int user in args.NewItems) + { + if (user == api.LocalUser.Value.Id) + spectatorClient.WatchUser(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + + foreach (int user in args.OldItems) + { + if (user == api.LocalUser.Value.Id) + spectatorClient.StopWatchingUser(user); + } + + break; + } + }, true); + + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] { - Recorder = recorder = new TestReplayRecorder + new Drawable[] { - ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - new Box + Recorder = recorder = new TestReplayRecorder + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container { - Colour = Color4.Brown, RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Sending", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } }, - new OsuSpriteText - { - Text = "Sending", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() } }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Clock = new FramedClock(manualClock), + ReplayInputHandler = replayHandler = 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 = "Receiving", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + } } }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - Clock = new FramedClock(manualClock), - ReplayInputHandler = replayHandler = 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 = "Receiving", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - } - } + latencyDisplay = new OsuSpriteText() + }; }); - - Add(latencyDisplay = new OsuSpriteText()); - }); + } private void onNewFrames(int userId, FrameDataBundle frames) { @@ -189,6 +197,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestBasic() { + AddStep("Wait for user input", () => { }); } private double latency = SpectatorClient.TIME_BETWEEN_SENDS; @@ -232,11 +241,15 @@ namespace osu.Game.Tests.Visual.Gameplay [TearDownSteps] public void TearDown() { - AddStep("stop recorder", () => - { - recorder.Expire(); - spectatorClient.OnNewFrames -= onNewFrames; - }); + AddStep("stop recorder", cleanUpState); + } + + private void cleanUpState() + { + // Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`. + recorder?.RemoveAndDisposeImmediately(); + recorder = null; + spectatorClient.OnNewFrames -= onNewFrames; } public class TestFramedReplayInputHandler : FramedReplayInputHandler diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs index a718a98aa6..95603b5c04 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs @@ -57,7 +57,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkForFirstSamplePlayback() { - AddUntilStep("storyboard loaded", () => Player.Beatmap.Value.StoryboardLoaded); + AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null); AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying)); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 48a97d54f7..69798dcb82 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -53,8 +53,8 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(null); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space)); + AddAssert("player is no longer current screen", () => !Player.IsCurrentScreen()); AddUntilStep("wait for score shown", () => Player.IsScoreShown); - AddUntilStep("time less than storyboard duration", () => Player.GameplayClockContainer.GameplayClock.CurrentTime < currentStoryboardDuration); } [Test] diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index 55e453c3d3..ee9363fa12 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; @@ -33,11 +34,11 @@ namespace osu.Game.Tests.Visual.Menus Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null; // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. - AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()).Wait(), 5); + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()).WaitSafely(), 5); AddStep("import beatmap with track", () => { - var setWithTrack = Game.BeatmapManager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).Result; + var setWithTrack = Game.BeatmapManager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).GetResultSafely(); Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(setWithTrack.Value.Beatmaps.First()); }); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index 57d60cea9e..c65595d82e 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Menus private TestToolbar toolbar; [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 357db16e2c..c4d7bd7e6a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -5,7 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Graphics.UserInterface; +using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; @@ -20,7 +20,6 @@ using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.Play; using osu.Game.Tests.Resources; -using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -31,17 +30,16 @@ namespace osu.Game.Tests.Visual.Multiplayer protected BeatmapInfo InitialBeatmap { get; private set; } protected BeatmapInfo OtherBeatmap { get; private set; } - protected IScreen CurrentScreen => multiplayerScreenStack.CurrentScreen; - protected IScreen CurrentSubScreen => multiplayerScreenStack.MultiplayerScreen.CurrentSubScreen; + protected IScreen CurrentScreen => multiplayerComponents.CurrentScreen; + protected IScreen CurrentSubScreen => multiplayerComponents.MultiplayerScreen.CurrentSubScreen; private BeatmapManager beatmaps; private RulesetStore rulesets; private BeatmapSetInfo importedSet; - private TestMultiplayerScreenStack multiplayerScreenStack; + private TestMultiplayerComponents multiplayerComponents; - protected TestMultiplayerClient Client => multiplayerScreenStack.Client; - protected TestMultiplayerRoomManager RoomManager => multiplayerScreenStack.RoomManager; + protected TestMultiplayerClient Client => multiplayerComponents.Client; [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -59,18 +57,18 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("import beatmap", () => { - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); InitialBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0); OtherBeatmap = importedSet.Beatmaps.Last(b => b.RulesetID == 0); }); - AddStep("load multiplayer", () => LoadScreen(multiplayerScreenStack = new TestMultiplayerScreenStack())); - AddUntilStep("wait for multiplayer to load", () => multiplayerScreenStack.IsLoaded); + AddStep("load multiplayer", () => LoadScreen(multiplayerComponents = new TestMultiplayerComponents())); + AddUntilStep("wait for multiplayer to load", () => multiplayerComponents.IsLoaded); AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddUntilStep("wait for lounge", () => multiplayerScreenStack.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); - AddStep("open room", () => multiplayerScreenStack.ChildrenOfType().Single().Open(new Room + AddUntilStep("wait for lounge", () => multiplayerComponents.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open(new Room { Name = { Value = "Test Room" }, QueueMode = { Value = Mode }, @@ -87,13 +85,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddWaitStep("wait for transition", 2); - AddStep("create room", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); + ClickButtonWhenEnabled(); - AddUntilStep("wait for join", () => RoomManager.RoomJoined); + AddUntilStep("wait for join", () => Client.RoomJoined); } [Test] @@ -105,24 +99,13 @@ namespace osu.Game.Tests.Visual.Multiplayer protected void RunGameplay() { AddUntilStep("wait for idle", () => Client.LocalUser?.State == MultiplayerUserState.Idle); - clickReadyButton(); + ClickButtonWhenEnabled(); AddUntilStep("wait for ready", () => Client.LocalUser?.State == MultiplayerUserState.Ready); - clickReadyButton(); + ClickButtonWhenEnabled(); - AddUntilStep("wait for player", () => multiplayerScreenStack.CurrentScreen is Player player && player.IsLoaded); - AddStep("exit player", () => multiplayerScreenStack.MultiplayerScreen.MakeCurrent()); - } - - private void clickReadyButton() - { - AddUntilStep("wait for ready button to be enabled", () => this.ChildrenOfType().Single().ChildrenOfType - public class TestMultiplayerScreenStack : OsuScreen + public class TestMultiplayerComponents : OsuScreen { public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen => multiplayerScreen; @@ -42,14 +42,18 @@ namespace osu.Game.Tests.Visual private readonly OsuScreenStack screenStack; private readonly TestMultiplayer multiplayerScreen; - public TestMultiplayerScreenStack() + public TestMultiplayerComponents() { multiplayerScreen = new TestMultiplayer(); InternalChildren = new Drawable[] { Client = new TestMultiplayerClient(RoomManager), - screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both } + screenStack = new OsuScreenStack + { + Name = nameof(TestMultiplayerComponents), + RelativeSizeAxes = Axes.Both + } }; screenStack.Push(multiplayerScreen); @@ -61,7 +65,7 @@ namespace osu.Game.Tests.Visual ((DummyAPIAccess)api).HandleRequest = request => multiplayerScreen.RequestsHandler.HandleRequest(request, api.LocalUser.Value, game); } - public override bool OnBackButton() => multiplayerScreen.OnBackButton(); + public override bool OnBackButton() => (screenStack.CurrentScreen as OsuScreen)?.OnBackButton() ?? base.OnBackButton(); public override bool OnExiting(IScreen next) { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index e5bcc08924..ede89c6096 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -135,6 +135,35 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60)); } + [TestCase(true)] + [TestCase(false)] + public void TestEarlyActivationEffectPoint(bool earlyActivating) + { + double earlyActivationMilliseconds = earlyActivating ? 100 : 0; + ControlPoint actualEffectPoint = null; + + AddStep($"set early activation to {earlyActivationMilliseconds}", () => beatContainer.EarlyActivationMilliseconds = earlyActivationMilliseconds); + + AddStep("seek before kiai effect point", () => + { + ControlPoint expectedEffectPoint = Beatmap.Value.Beatmap.ControlPointInfo.EffectPoints.First(ep => ep.KiaiMode); + actualEffectPoint = null; + beatContainer.AllowMistimedEventFiring = false; + + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + { + if (Precision.AlmostEquals(gameplayClockContainer.CurrentTime + earlyActivationMilliseconds, expectedEffectPoint.Time, BeatSyncedContainer.MISTIMED_ALLOWANCE)) + actualEffectPoint = effectControlPoint; + }; + + gameplayClockContainer.Seek(expectedEffectPoint.Time - earlyActivationMilliseconds); + }); + + AddUntilStep("wait for effect point", () => actualEffectPoint != null); + + AddAssert("effect has kiai", () => actualEffectPoint != null && ((EffectControlPoint)actualEffectPoint).KiaiMode); + } + private class TestBeatSyncedContainer : BeatSyncedContainer { private const int flash_layer_height = 150; @@ -145,6 +174,12 @@ namespace osu.Game.Tests.Visual.UserInterface set => base.AllowMistimedEventFiring = value; } + public new double EarlyActivationMilliseconds + { + get => base.EarlyActivationMilliseconds; + set => base.EarlyActivationMilliseconds = value; + } + private readonly InfoString timingPointCount; private readonly InfoString currentTimingPoint; private readonly InfoString beatCount; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs new file mode 100644 index 0000000000..e3d47f08c6 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs @@ -0,0 +1,55 @@ +// 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.Sprites; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapListingCardSizeTabControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly Bindable cardSize = new Bindable(); + + private SpriteText cardSizeText; + + [BackgroundDependencyLoader] + private void load() + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + cardSizeText = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 24) + }, + new BeatmapListingCardSizeTabControl + { + Current = cardSize, + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + cardSize.BindValueChanged(size => cardSizeText.Text = $"Current size: {size.NewValue}", true); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 9f0f4a6b8b..a436fc0bfa 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Extensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Platform; using osu.Framework.Testing; @@ -85,13 +86,13 @@ namespace osu.Game.Tests.Visual.UserInterface dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, ContextFactory, Scheduler)); - beatmapInfo = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).Result.Value.Beatmaps[0]; + beatmapInfo = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely().Value.Beatmaps[0]; for (int i = 0; i < 50; i++) { var score = new ScoreInfo { - OnlineScoreID = i, + OnlineID = i, BeatmapInfo = beatmapInfo, BeatmapInfoID = beatmapInfo.ID, Accuracy = RNG.NextDouble(), @@ -101,7 +102,7 @@ namespace osu.Game.Tests.Visual.UserInterface User = new APIUser { Username = "TestUser" }, }; - importedScores.Add(scoreManager.Import(score).Result.Value); + importedScores.Add(scoreManager.Import(score).GetResultSafely().Value); } return dependencies; @@ -163,7 +164,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("wait for fetch", () => leaderboard.Scores != null); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scoreBeingDeleted.OnlineScoreID)); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); } [Test] @@ -171,7 +172,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); AddUntilStep("wait for fetch", () => leaderboard.Scores != null); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != importedScores[0].OnlineScoreID)); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != importedScores[0].OnlineID)); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index 493e2f54e5..544581082e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -29,10 +29,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("Display toast with lengthy text", () => osd.Display(new LengthyToast())); AddAssert("Toast width is greater than 240", () => osd.Child.Width > 240); - AddRepeatStep("Change toggle (no bind)", () => config.ToggleSetting(TestConfigSetting.ToggleSettingNoKeybind), 2); - AddRepeatStep("Change toggle (with bind)", () => config.ToggleSetting(TestConfigSetting.ToggleSettingWithKeybind), 2); - AddRepeatStep("Change enum (no bind)", () => config.IncrementEnumSetting(TestConfigSetting.EnumSettingNoKeybind), 3); - AddRepeatStep("Change enum (with bind)", () => config.IncrementEnumSetting(TestConfigSetting.EnumSettingWithKeybind), 3); + AddRepeatStep("Change toggle (no bind)", () => config.ToggleSetting(TestConfigSetting.ToggleSettingNoKeyBind), 2); + AddRepeatStep("Change toggle (with bind)", () => config.ToggleSetting(TestConfigSetting.ToggleSettingWithKeyBind), 2); + AddRepeatStep("Change enum (no bind)", () => config.IncrementEnumSetting(TestConfigSetting.EnumSettingNoKeyBind), 3); + AddRepeatStep("Change enum (with bind)", () => config.IncrementEnumSetting(TestConfigSetting.EnumSettingWithKeyBind), 3); } private class TestConfigManager : ConfigManager @@ -44,10 +44,10 @@ namespace osu.Game.Tests.Visual.UserInterface protected override void InitialiseDefaults() { - SetDefault(TestConfigSetting.ToggleSettingNoKeybind, false); - SetDefault(TestConfigSetting.EnumSettingNoKeybind, EnumSetting.Setting1); - SetDefault(TestConfigSetting.ToggleSettingWithKeybind, false); - SetDefault(TestConfigSetting.EnumSettingWithKeybind, EnumSetting.Setting1); + SetDefault(TestConfigSetting.ToggleSettingNoKeyBind, false); + SetDefault(TestConfigSetting.EnumSettingNoKeyBind, EnumSetting.Setting1); + SetDefault(TestConfigSetting.ToggleSettingWithKeyBind, false); + SetDefault(TestConfigSetting.EnumSettingWithKeyBind, EnumSetting.Setting1); base.InitialiseDefaults(); } @@ -64,10 +64,10 @@ namespace osu.Game.Tests.Visual.UserInterface public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { - new TrackedSetting(TestConfigSetting.ToggleSettingNoKeybind, b => new SettingDescription(b, "toggle setting with no keybind", b ? "enabled" : "disabled")), - new TrackedSetting(TestConfigSetting.EnumSettingNoKeybind, v => new SettingDescription(v, "enum setting with no keybind", v.ToString())), - new TrackedSetting(TestConfigSetting.ToggleSettingWithKeybind, b => new SettingDescription(b, "toggle setting with keybind", b ? "enabled" : "disabled", "fake keybind")), - new TrackedSetting(TestConfigSetting.EnumSettingWithKeybind, v => new SettingDescription(v, "enum setting with keybind", v.ToString(), "fake keybind")), + new TrackedSetting(TestConfigSetting.ToggleSettingNoKeyBind, b => new SettingDescription(b, "toggle setting with no keybind", b ? "enabled" : "disabled")), + new TrackedSetting(TestConfigSetting.EnumSettingNoKeyBind, v => new SettingDescription(v, "enum setting with no keybind", v.ToString())), + new TrackedSetting(TestConfigSetting.ToggleSettingWithKeyBind, b => new SettingDescription(b, "toggle setting with keybind", b ? "enabled" : "disabled", "fake keybind")), + new TrackedSetting(TestConfigSetting.EnumSettingWithKeyBind, v => new SettingDescription(v, "enum setting with keybind", v.ToString(), "fake keybind")), }; protected override void PerformLoad() @@ -79,10 +79,10 @@ namespace osu.Game.Tests.Visual.UserInterface private enum TestConfigSetting { - ToggleSettingNoKeybind, - EnumSettingNoKeybind, - ToggleSettingWithKeybind, - EnumSettingWithKeybind + ToggleSettingNoKeyBind, + EnumSettingNoKeyBind, + ToggleSettingWithKeyBind, + EnumSettingWithKeyBind } private enum EnumSetting diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs index 6a41d08f01..2eb5a8014e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -9,47 +10,96 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuAnimatedButton : OsuGridTestScene + public class TestSceneOsuAnimatedButton : OsuTestScene { - public TestSceneOsuAnimatedButton() - : base(3, 2) + [Test] + public void TestRelativeSized() { - Cell(0).Add(new BaseContainer("relative sized") + AddStep("add button", () => Child = new BaseContainer("relative sized") { RelativeSizeAxes = Axes.Both, + Action = () => { } }); + } - Cell(1).Add(new BaseContainer("auto sized") + [Test] + public void TestAutoSized() + { + AddStep("add button", () => Child = new BaseContainer("auto sized") { - AutoSizeAxes = Axes.Both + AutoSizeAxes = Axes.Both, + Action = () => { } }); + } - Cell(2).Add(new BaseContainer("relative Y auto X") + [Test] + public void TestRelativeYAutoX() + { + AddStep("add button", () => Child = new BaseContainer("relative Y auto X") { RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X + AutoSizeAxes = Axes.X, + Action = () => { } }); + } - Cell(3).Add(new BaseContainer("relative X auto Y") + [Test] + public void TestRelativeXAutoY() + { + AddStep("add button", () => Child = new BaseContainer("relative X auto Y") { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y + AutoSizeAxes = Axes.Y, + Action = () => { } }); + } - Cell(4).Add(new BaseContainer("fixed") + [Test] + public void TestFixed1() + { + AddStep("add button", () => Child = new BaseContainer("fixed") { Size = new Vector2(100), + Action = () => { } }); + } - Cell(5).Add(new BaseContainer("fixed") + [Test] + public void TestFixed2() + { + AddStep("add button", () => Child = new BaseContainer("fixed") { Size = new Vector2(100, 50), + Action = () => { } + }); + } + + [Test] + public void TestToggleEnabled() + { + BaseContainer button = null; + + AddStep("add button", () => Child = button = new BaseContainer("fixed") + { + Size = new Vector2(200), }); AddToggleStep("toggle enabled", toggle => { for (int i = 0; i < 6; i++) - ((BaseContainer)Cell(i).Child).Action = toggle ? () => { } : (Action)null; + button.Action = toggle ? () => { } : (Action)null; + }); + } + + [Test] + public void TestInitiallyDisabled() + { + AddStep("add disabled button", () => + { + Child = new BaseContainer("disabled") + { + Size = new Vector2(100) + }; }); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuButton.cs new file mode 100644 index 0000000000..9d086cce5c --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuButton.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 System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuButton : OsuTestScene + { + [Test] + public void TestToggleEnabled() + { + OsuButton button = null; + + AddStep("add button", () => Child = button = new OsuButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200), + Text = "Button" + }); + + AddToggleStep("toggle enabled", toggle => + { + for (int i = 0; i < 6; i++) + button.Action = toggle ? () => { } : (Action)null; + }); + } + + [Test] + public void TestInitiallyDisabled() + { + AddStep("add button", () => Child = new OsuButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200), + Text = "Button" + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs index fc1866cdf3..353f84c546 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs @@ -62,6 +62,6 @@ namespace osu.Game.Tests.Visual.UserInterface } private void clearTextboxes(IEnumerable textBoxes) => AddStep("clear textbox", () => textBoxes.ForEach(textBox => textBox.Text = null)); - private void expectedValue(IEnumerable textBoxes, string value) => AddAssert("expected textbox value", () => textBoxes.All(textbox => textbox.Text == value)); + private void expectedValue(IEnumerable textBoxes, string value) => AddAssert("expected textbox value", () => textBoxes.All(textBox => textBox.Text == value)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs new file mode 100644 index 0000000000..c99ac52cb1 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs @@ -0,0 +1,79 @@ +// 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.Testing; +using osu.Game.Graphics.UserInterface.PageSelector; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestScenePageSelector : OsuTestScene + { + [Cached] + private OverlayColourProvider provider { get; } = new OverlayColourProvider(OverlayColourScheme.Green); + + private readonly PageSelector pageSelector; + + public TestScenePageSelector() + { + AddRange(new Drawable[] + { + pageSelector = new PageSelector + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }); + } + + [Test] + public void TestOmittedPages() + { + setAvailablePages(100); + + selectPageIndex(0); + checkVisiblePageNumbers(new[] { 1, 2, 3, 100 }); + + selectPageIndex(6); + checkVisiblePageNumbers(new[] { 1, 5, 6, 7, 8, 9, 100 }); + + selectPageIndex(49); + checkVisiblePageNumbers(new[] { 1, 48, 49, 50, 51, 52, 100 }); + + selectPageIndex(99); + checkVisiblePageNumbers(new[] { 1, 98, 99, 100 }); + } + + [Test] + public void TestResetCurrentPage() + { + setAvailablePages(10); + selectPageIndex(6); + setAvailablePages(11); + AddAssert("Page 1 is current", () => pageSelector.CurrentPage.Value == 0); + } + + [Test] + public void TestOutOfBoundsSelection() + { + setAvailablePages(10); + selectPageIndex(11); + AddAssert("Page 10 is current", () => pageSelector.CurrentPage.Value == pageSelector.AvailablePages.Value - 1); + + selectPageIndex(-1); + AddAssert("Page 1 is current", () => pageSelector.CurrentPage.Value == 0); + } + + private void checkVisiblePageNumbers(int[] expected) => AddAssert($"Sequence is {string.Join(',', expected.Select(i => i.ToString()))}", () => pageSelector.ChildrenOfType().Select(p => p.PageNumber).SequenceEqual(expected)); + + private void selectPageIndex(int pageIndex) => + AddStep($"Select page {pageIndex}", () => pageSelector.CurrentPage.Value = pageIndex); + + private void setAvailablePages(int availablePages) => + AddStep($"Set available pages to {availablePages}", () => pageSelector.AvailablePages.Value = availablePages); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs index d30f1e8889..736df7dec1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; @@ -13,7 +14,6 @@ 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.Rulesets; using osu.Game.Tests.Beatmaps.IO; using osuTK; @@ -26,15 +26,12 @@ namespace osu.Game.Tests.Visual.UserInterface private BeatmapSetInfo testBeatmap; private IAPIProvider api; - [Resolved] - private BeatmapManager beatmaps { get; set; } - [BackgroundDependencyLoader] - private void load(OsuGameBase osu, IAPIProvider api, RulesetStore rulesets) + private void load(OsuGameBase osu, IAPIProvider api) { this.api = api; - testBeatmap = ImportBeatmapTest.LoadOszIntoOsu(osu).Result; + testBeatmap = ImportBeatmapTest.LoadOszIntoOsu(osu).GetResultSafely(); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs index db1c90f287..7986f14d1d 100644 --- a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs +++ b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.UserInterface new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoam + Colour = colours.GreySeaFoam }, CreateContent() }); diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs index 8139387a96..b678f69b8f 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs @@ -6,19 +6,20 @@ using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; +using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; namespace osu.Game.Tournament.Tests.Components { public class TestSceneTournamentBeatmapPanel : TournamentTestScene { + /// + /// Warning: the below API instance is actually the online API, rather than the dummy API provided by the test. + /// It cannot be trivially replaced because setting to causes to no longer be usable. + /// [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs index 3cd13df0d3..9feef36a02 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tournament.Tests.Components private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private FillFlowContainer fillFlow; diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index d149ec145b..fc5d3b652f 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -2,10 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; +using System.Linq; using NUnit.Framework; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Tests; using osu.Game.Tournament.Configuration; @@ -17,7 +18,7 @@ namespace osu.Game.Tournament.Tests.NonVisual [Test] public void TestDefaultDirectory() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestDefaultDirectory))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { @@ -36,9 +37,9 @@ namespace osu.Game.Tournament.Tests.NonVisual [Test] public void TestCustomDirectory() { - using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestCustomDirectory))) // don't use clean run as we are writing a config file. + using (HeadlessGameHost host = new TestRunHeadlessGameHost(nameof(TestCustomDirectory))) // don't use clean run as we are writing a config file. { - string osuDesktopStorage = PrepareBasePath(nameof(TestCustomDirectory)); + string osuDesktopStorage = Path.Combine(host.UserStoragePaths.First(), nameof(TestCustomDirectory)); const string custom_tournament = "custom"; // need access before the game has constructed its own storage yet. @@ -60,15 +61,6 @@ namespace osu.Game.Tournament.Tests.NonVisual finally { host.Exit(); - - try - { - if (Directory.Exists(osuDesktopStorage)) - Directory.Delete(osuDesktopStorage, true); - } - catch - { - } } } } @@ -76,9 +68,9 @@ namespace osu.Game.Tournament.Tests.NonVisual [Test] public void TestMigration() { - using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestMigration))) // don't use clean run as we are writing test files for migration. + using (HeadlessGameHost host = new TestRunHeadlessGameHost(nameof(TestMigration))) // don't use clean run as we are writing test files for migration. { - string osuRoot = PrepareBasePath(nameof(TestMigration)); + string osuRoot = Path.Combine(host.UserStoragePaths.First(), nameof(TestMigration)); string configFile = Path.Combine(osuRoot, "tournament.ini"); if (File.Exists(configFile)) @@ -96,7 +88,7 @@ namespace osu.Game.Tournament.Tests.NonVisual Directory.CreateDirectory(flagsPath); // Define testing files corresponding to the specific file migrations that are needed - string bracketFile = Path.Combine(osuRoot, "bracket.json"); + string bracketFile = Path.Combine(osuRoot, TournamentGameBase.BRACKET_FILENAME); string drawingsConfig = Path.Combine(osuRoot, "drawings.ini"); string drawingsFile = Path.Combine(osuRoot, "drawings.txt"); @@ -133,7 +125,7 @@ namespace osu.Game.Tournament.Tests.NonVisual Assert.That(storage.GetFullPath("."), Is.EqualTo(migratedPath)); - Assert.True(storage.Exists("bracket.json")); + Assert.True(storage.Exists(TournamentGameBase.BRACKET_FILENAME)); Assert.True(storage.Exists("drawings.txt")); Assert.True(storage.Exists("drawings_results.txt")); @@ -146,28 +138,8 @@ namespace osu.Game.Tournament.Tests.NonVisual finally { host.Exit(); - - try - { - if (Directory.Exists(osuRoot)) - Directory.Delete(osuRoot, true); - } - catch - { - } } } } - - public static string PrepareBasePath(string testInstance) - { - string basePath = Path.Combine(RuntimeInfo.StartupDirectory, "headless", testInstance); - - // manually clean before starting in case there are left-over files at the test site. - if (Directory.Exists(basePath)) - Directory.Delete(basePath, true); - - return basePath; - } } } diff --git a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs index 692cb3870c..db019f9242 100644 --- a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs @@ -15,7 +15,7 @@ namespace osu.Game.Tournament.Tests.NonVisual [Test] public void TestUnavailableRuleset() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUnavailableRuleset))) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs index db89855db7..03252e3be6 100644 --- a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Tournament.IO; using osu.Game.Tournament.IPC; @@ -17,9 +19,9 @@ namespace osu.Game.Tournament.Tests.NonVisual public void CheckIPCLocation() { // don't use clean run because files are being written before osu! launches. - using (HeadlessGameHost host = new HeadlessGameHost(nameof(CheckIPCLocation))) + using (var host = new TestRunHeadlessGameHost(nameof(CheckIPCLocation))) { - string basePath = CustomTourneyDirectoryTest.PrepareBasePath(nameof(CheckIPCLocation)); + string basePath = Path.Combine(host.UserStoragePaths.First(), nameof(CheckIPCLocation)); // Set up a fake IPC client for the IPC Storage to switch to. string testStableInstallDirectory = Path.Combine(basePath, "stable-ce"); @@ -34,7 +36,7 @@ namespace osu.Game.Tournament.Tests.NonVisual TournamentStorage storage = (TournamentStorage)osu.Dependencies.Get(); FileBasedIPC ipc = null; - WaitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC) != null, @"ipc could not be populated in a reasonable amount of time"); + WaitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC)?.IsLoaded == true, @"ipc could not be populated in a reasonable amount of time"); Assert.True(ipc.SetIPCLocation(testStableInstallDirectory)); Assert.True(storage.AllTournaments.Exists("stable.json")); @@ -42,15 +44,6 @@ namespace osu.Game.Tournament.Tests.NonVisual finally { host.Exit(); - - try - { - if (Directory.Exists(basePath)) - Directory.Delete(basePath, true); - } - catch - { - } } } } diff --git a/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs b/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs index 4d134ce4af..53591da07b 100644 --- a/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs +++ b/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs @@ -2,14 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Platform; namespace osu.Game.Tournament.Tests { public class TestSceneTournamentSceneManager : TournamentTestScene { [BackgroundDependencyLoader] - private void load(Storage storage) + private void load() { Add(new TournamentSceneManager()); } diff --git a/osu.Game.Tournament/Components/DrawableTeamTitle.cs b/osu.Game.Tournament/Components/DrawableTeamTitle.cs index 5aac37259f..6732eb152f 100644 --- a/osu.Game.Tournament/Components/DrawableTeamTitle.cs +++ b/osu.Game.Tournament/Components/DrawableTeamTitle.cs @@ -4,7 +4,6 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components @@ -22,7 +21,7 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load() { if (team == null) return; diff --git a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs index b9442a67f5..367e447947 100644 --- a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs +++ b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs @@ -5,7 +5,6 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Tournament.Models; @@ -33,7 +32,7 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load() { if (Team == null) return; diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index 364cccd076..4189f3ccb5 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -9,7 +9,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -45,7 +44,7 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader] - private void load(LadderInfo ladder, TextureStore textures) + private void load(LadderInfo ladder) { currentMatch.BindValueChanged(matchChanged); currentMatch.BindTo(ladder.CurrentMatch); diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs index 0fde263bc8..ed8a36c220 100644 --- a/osu.Game.Tournament/Components/TournamentModIcon.cs +++ b/osu.Game.Tournament/Components/TournamentModIcon.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tournament.Components private readonly string modAcronym; [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } public TournamentModIcon(string modAcronym) { diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs index 02cf567837..347d368a04 100644 --- a/osu.Game.Tournament/IO/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -86,7 +86,7 @@ namespace osu.Game.Tournament.IO DeleteRecursive(source); } - moveFileIfExists("bracket.json", destination); + moveFileIfExists(TournamentGameBase.BRACKET_FILENAME, destination); moveFileIfExists("drawings.txt", destination); moveFileIfExists("drawings_results.txt", destination); moveFileIfExists("drawings.ini", destination); diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index a57f9fd691..5278d538d2 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tournament.IPC protected IAPIProvider API { get; private set; } [Resolved] - protected RulesetStore Rulesets { get; private set; } + protected IRulesetStore Rulesets { get; private set; } [Resolved] private GameHost host { get; set; } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs index c7060bd538..6fec74f95b 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs @@ -85,14 +85,14 @@ namespace osu.Game.Tournament.Screens.Drawings.Components private ScrollState scrollState; - private void setScrollState(ScrollState newstate) + private void setScrollState(ScrollState newState) { - if (scrollState == newstate) + if (scrollState == newState) return; delayedStateChangeDelegate?.Cancel(); - switch (scrollState = newstate) + switch (scrollState = newState) { case ScrollState.Scrolling: resetSelected(); diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index f6bc607447..5c12d83d1c 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -12,7 +12,6 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; -using osu.Game.Rulesets; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osuTK; @@ -218,7 +217,7 @@ namespace osu.Game.Tournament.Screens.Editors } [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + private void load() { beatmapId.Value = Model.ID; beatmapId.BindValueChanged(id => diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 9abf1d3adb..5cdfe7dc08 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -12,7 +12,6 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; -using osu.Game.Rulesets; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osuTK; @@ -25,9 +24,6 @@ namespace osu.Game.Tournament.Screens.Editors protected override BindableList Storage => team.SeedingResults; - [Resolved(canBeNull: true)] - private TournamentSceneManager sceneManager { get; set; } - public SeedingEditorScreen(TournamentTeam team, TournamentScreen parentScreen) : base(parentScreen) { @@ -38,9 +34,6 @@ namespace osu.Game.Tournament.Screens.Editors { public SeedingResult Model { get; } - [Resolved] - private LadderInfo ladderInfo { get; set; } - public SeedingResultRow(TournamentTeam team, SeedingResult round) { Model = round; @@ -226,7 +219,7 @@ namespace osu.Game.Tournament.Screens.Editors } [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + private void load() { beatmapId.Value = Model.ID; beatmapId.BindValueChanged(id => diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs index 813bed86ae..db15a46fc8 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs @@ -11,7 +11,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.IPC; -using osu.Game.Tournament.Models; using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components @@ -91,7 +90,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components } [BackgroundDependencyLoader] - private void load(LadderInfo ladder, MatchIPCInfo ipc) + private void load(MatchIPCInfo ipc) { score1.BindValueChanged(_ => updateScores()); score1.BindTo(ipc.Score1); diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index 7e7c719152..f900dd7eac 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -6,7 +6,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; @@ -37,7 +36,7 @@ namespace osu.Game.Tournament.Screens.Gameplay private Drawable chroma; [BackgroundDependencyLoader] - private void load(LadderInfo ladder, MatchIPCInfo ipc, Storage storage) + private void load(LadderInfo ladder, MatchIPCInfo ipc) { this.ipc = ipc; diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs index bb1e4d2eff..ea453a53ca 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs @@ -81,7 +81,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, LadderEditorScreen ladderEditor) + private void load(LadderEditorScreen ladderEditor) { this.ladderEditor = ladderEditor; diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index 3e950310cf..5729e779c4 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -208,10 +208,10 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { if (Match.Round.Value == null) return; - int instaWinAmount = Match.Round.Value.BestOf.Value / 2; + int instantWinAmount = Match.Round.Value.BestOf.Value / 2; Match.Completed.Value = Match.Round.Value.BestOf.Value > 0 - && (Match.Team1Score.Value + Match.Team2Score.Value >= Match.Round.Value.BestOf.Value || Match.Team1Score.Value > instaWinAmount || Match.Team2Score.Value > instaWinAmount); + && (Match.Team1Score.Value + Match.Team2Score.Value >= Match.Round.Value.BestOf.Value || Match.Team1Score.Value > instantWinAmount || Match.Team2Score.Value > instantWinAmount); } protected override void LoadComplete() diff --git a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs index 534c402f6c..ad6e304c80 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs @@ -9,8 +9,6 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; -using osu.Framework.Platform; -using osu.Game.Graphics; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Editors; @@ -30,7 +28,7 @@ namespace osu.Game.Tournament.Screens.Ladder protected Container Content; [BackgroundDependencyLoader] - private void load(OsuColour colours, Storage storage) + private void load() { normalPathColour = Color4Extensions.FromHex("#66D1FF"); losersPathColour = Color4Extensions.FromHex("#FFC700"); diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index e08be65465..84f38170ea 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -8,7 +8,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -25,7 +24,7 @@ namespace osu.Game.Tournament.Screens.Schedule private LadderInfo ladder; [BackgroundDependencyLoader] - private void load(LadderInfo ladder, Storage storage) + private void load(LadderInfo ladder) { this.ladder = ladder; diff --git a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs index 8e9b32231f..fb9ca46c2d 100644 --- a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs @@ -21,9 +21,6 @@ namespace osu.Game.Tournament.Screens.Setup { public class StablePathSelectScreen : TournamentScreen { - [Resolved] - private GameHost host { get; set; } - [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } @@ -53,7 +50,7 @@ namespace osu.Game.Tournament.Screens.Setup { new Box { - Colour = colours.GreySeafoamDark, + Colour = colours.GreySeaFoamDark, RelativeSizeAxes = Axes.Both, }, new GridContainer diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index cd74a75b10..0003e213e7 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; -using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; @@ -25,7 +24,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro private readonly Bindable currentTeam = new Bindable(); [BackgroundDependencyLoader] - private void load(Storage storage) + private void load() { RelativeSizeAxes = Axes.Both; diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index 74957cbca5..ef6f0b32ff 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Platform; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osuTK; @@ -17,7 +16,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro private Container mainContainer; [BackgroundDependencyLoader] - private void load(Storage storage) + private void load() { RelativeSizeAxes = Axes.Both; diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index ebe2908b74..11db7bfad9 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -23,7 +22,7 @@ namespace osu.Game.Tournament.Screens.TeamWin private TourneyVideo redWinVideo; [BackgroundDependencyLoader] - private void load(LadderInfo ladder, Storage storage) + private void load() { RelativeSizeAxes = Axes.Both; diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index f03f815b83..5d613894d4 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -71,7 +71,7 @@ namespace osu.Game.Tournament loadingSpinner.Expire(); Logger.Error(t.Exception, "Couldn't load bracket with error"); - Add(new WarningBox("Your bracket.json file could not be parsed. Please check runtime.log for more details.")); + Add(new WarningBox($"Your {BRACKET_FILENAME} file could not be parsed. Please check runtime.log for more details.")); }); return; diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index d2f146c4c2..d08322a3e8 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.IO.Stores; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Online.API.Requests; @@ -26,15 +27,15 @@ namespace osu.Game.Tournament [Cached(typeof(TournamentGameBase))] public class TournamentGameBase : OsuGameBase { - private const string bracket_filename = "bracket.json"; + public const string BRACKET_FILENAME = @"bracket.json"; private LadderInfo ladder; private TournamentStorage storage; private DependencyContainer dependencies; private FileBasedIPC ipc; - protected Task BracketLoadTask => taskCompletionSource.Task; + protected Task BracketLoadTask => bracketLoadTaskCompletionSource.Task; - private readonly TaskCompletionSource taskCompletionSource = new TaskCompletionSource(); + private readonly TaskCompletionSource bracketLoadTaskCompletionSource = new TaskCompletionSource(); protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -71,9 +72,9 @@ namespace osu.Game.Tournament { try { - if (storage.Exists(bracket_filename)) + if (storage.Exists(BRACKET_FILENAME)) { - using (Stream stream = storage.GetStream(bracket_filename, FileAccess.Read, FileMode.Open)) + using (Stream stream = storage.GetStream(BRACKET_FILENAME, FileAccess.Read, FileMode.Open)) using (var sr = new StreamReader(stream)) ladder = JsonConvert.DeserializeObject(sr.ReadToEnd(), new JsonPointConverter()); } @@ -144,7 +145,7 @@ namespace osu.Game.Tournament } catch (Exception e) { - taskCompletionSource.SetException(e); + bracketLoadTaskCompletionSource.SetException(e); return; } @@ -156,7 +157,7 @@ namespace osu.Game.Tournament dependencies.CacheAs(ipc = new FileBasedIPC()); Add(ipc); - taskCompletionSource.SetResult(true); + bracketLoadTaskCompletionSource.SetResult(true); initialisationText.Expire(); }); @@ -292,6 +293,12 @@ namespace osu.Game.Tournament protected virtual void SaveChanges() { + if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully) + { + Logger.Log("Inhibiting bracket save as bracket parsing failed"); + return; + } + foreach (var r in ladder.Rounds) r.Matches = ladder.Matches.Where(p => p.Round.Value == r).Select(p => p.ID).ToList(); @@ -309,7 +316,7 @@ namespace osu.Game.Tournament Converters = new JsonConverter[] { new JsonPointConverter() } }); - using (var stream = storage.GetStream(bracket_filename, FileAccess.Write, FileMode.Create)) + using (var stream = storage.GetStream(BRACKET_FILENAME, FileAccess.Write, FileMode.Create)) using (var sw = new StreamWriter(stream)) sw.Write(serialisedLadder); } diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index 914d1163ad..80a9c07cde 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -7,11 +7,9 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Tournament.Components; -using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens; using osu.Game.Tournament.Screens.Drawings; using osu.Game.Tournament.Screens.Editors; @@ -52,7 +50,7 @@ namespace osu.Game.Tournament } [BackgroundDependencyLoader] - private void load(LadderInfo ladder, Storage storage) + private void load() { InternalChildren = new Drawable[] { diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index e631d35180..6d56d152f1 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -18,9 +18,6 @@ namespace osu.Game.Audio private readonly BindableDouble muteBindable = new BindableDouble(); - [Resolved] - private AudioManager audio { get; set; } - private ITrackStore trackStore; protected TrackManagerPreviewTrack CurrentTrack; diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index f69a10f000..435183fe92 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -93,8 +93,12 @@ namespace osu.Game.Beatmaps if (t.Time > lastTime) return (beatLength: t.BeatLength, 0); + // osu-stable forced the first control point to start at 0. + // This is reproduced here to maintain compatibility around osu!mania scroll speed and song select display. + double currentTime = i == 0 ? 0 : t.Time; double nextTime = i == ControlPointInfo.TimingPoints.Count - 1 ? lastTime : ControlPointInfo.TimingPoints[i + 1].Time; - return (beatLength: t.BeatLength, duration: nextTime - t.Time); + + return (beatLength: t.BeatLength, duration: nextTime - currentTime); }) // Aggregate durations into a set of (beatLength, duration) tuples for each beat length .GroupBy(t => Math.Round(t.beatLength * 1000) / 1000) diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs index dfd21469fa..65d1fb8286 100644 --- a/osu.Game/Beatmaps/BeatmapDifficulty.cs +++ b/osu.Game/Beatmaps/BeatmapDifficulty.cs @@ -15,6 +15,8 @@ namespace osu.Game.Beatmaps 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; diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 61717c18d5..f760c25170 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; @@ -135,7 +136,7 @@ namespace osu.Game.Beatmaps var localRulesetInfo = rulesetInfo as RulesetInfo; // Difficulty can only be computed if the beatmap and ruleset are locally available. - if (localBeatmapInfo == null || localBeatmapInfo.ID == 0 || localRulesetInfo == null) + if (localBeatmapInfo?.IsManaged != true || localRulesetInfo == null) { // If not, fall back to the existing star difficulty (e.g. from an online source). return Task.FromResult(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0)); @@ -261,13 +262,18 @@ namespace osu.Game.Beatmaps // GetDifficultyAsync will fall back to existing data from IBeatmapInfo if not locally available // (contrary to GetAsync) GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken) - .ContinueWith(t => + .ContinueWith(task => { // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. Schedule(() => { - if (!cancellationToken.IsCancellationRequested && t.Result != null) - bindable.Value = t.Result; + if (cancellationToken.IsCancellationRequested) + return; + + var starDifficulty = task.GetResultSafely(); + + if (starDifficulty != null) + bindable.Value = starDifficulty.Value; }); }, cancellationToken); } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 36f82ac56c..4175d7ff6b 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -20,6 +20,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int BeatmapVersion; private int? onlineID; diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 8502b91096..ed7fe0bc91 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; @@ -91,7 +92,7 @@ namespace osu.Game.Beatmaps } }; - var imported = beatmapModelManager.Import(set).Result.Value; + var imported = beatmapModelManager.Import(set).GetResultSafely().Value; return GetWorkingBeatmap(imported.Beatmaps.First()); } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index b395f16c24..5da0264893 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -20,6 +20,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + public string Title { get; set; } = string.Empty; [JsonProperty("title_unicode")] diff --git a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs index ce50463f05..29dcf4d6aa 100644 --- a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs @@ -11,6 +11,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int BeatmapSetInfoID { get; set; } public int FileInfoID { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index ac7067edda..a3a8f8555f 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -18,6 +18,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + private int? onlineID; [Column("OnlineBeatmapSetID")] diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 8b00d0f7f2..3949e84f4a 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [Resolved] private Bindable ruleset { get; set; } @@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps /// private int? requestedUserId; - private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); + private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); private readonly IBindable apiState = new Bindable(); @@ -101,7 +101,7 @@ namespace osu.Game.Beatmaps /// Rulesets ordered descending by their respective recommended difficulties. /// The currently selected ruleset will always be first. /// - private IEnumerable orderedRulesets + private IEnumerable orderedRulesets { get { diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 6345085069..19026638ba 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -94,9 +94,9 @@ namespace osu.Game.Beatmaps.Drawables if (colourProvider != null) statusText.Colour = status == BeatmapOnlineStatus.Graveyard ? colourProvider.Background1 : colourProvider.Background3; else - statusText.Colour = status == BeatmapOnlineStatus.Graveyard ? colours.GreySeafoamLight : Color4.Black; + statusText.Colour = status == BeatmapOnlineStatus.Graveyard ? colours.GreySeaFoamLight : Color4.Black; - background.Colour = OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeafoamLighter; + background.Colour = OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeaFoamLighter; } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index 37c1bacda4..9a17d6dc9b 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -3,413 +3,100 @@ #nullable enable -using System.Collections.Generic; +using System; 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.Framework.Localisation; -using osu.Game.Beatmaps.Drawables.Cards.Buttons; -using osu.Game.Beatmaps.Drawables.Cards.Statistics; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; -using osu.Game.Overlays.BeatmapSet; -using osuTK; -using osu.Game.Resources.Localisation.Web; -using DownloadButton = osu.Game.Beatmaps.Drawables.Cards.Buttons.DownloadButton; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCard : OsuClickableContainer + public abstract class BeatmapCard : OsuClickableContainer { public const float TRANSITION_DURATION = 400; + public const float CORNER_RADIUS = 10; - private const float width = 408; - private const float height = 100; - private const float corner_radius = 10; - private const float icon_area_width = 30; + protected const float WIDTH = 430; - private readonly APIBeatmapSet beatmapSet; - private readonly Bindable favouriteState; + public IBindable Expanded { get; } - private readonly BeatmapDownloadTracker downloadTracker; + public readonly APIBeatmapSet BeatmapSet; - private BeatmapCardThumbnail thumbnail = null!; + protected readonly Bindable FavouriteState; - private Container rightAreaBackground = null!; - private Container rightAreaButtons = null!; + protected abstract Drawable IdleContent { get; } + protected abstract Drawable DownloadInProgressContent { get; } - private Container mainContent = null!; - private BeatmapCardContentBackground mainContentBackground = null!; - private FillFlowContainer statisticsContainer = null!; + protected readonly BeatmapDownloadTracker DownloadTracker; - private FillFlowContainer idleBottomContent = null!; - private BeatmapCardDownloadProgressBar downloadProgressBar = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - public BeatmapCard(APIBeatmapSet beatmapSet) + protected BeatmapCard(APIBeatmapSet beatmapSet, bool allowExpansion = true) : base(HoverSampleSet.Submit) { - this.beatmapSet = beatmapSet; - favouriteState = new Bindable(new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount)); - downloadTracker = new BeatmapDownloadTracker(beatmapSet); + Expanded = new BindableBool { Disabled = !allowExpansion }; + + BeatmapSet = beatmapSet; + FavouriteState = new Bindable(new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount)); + DownloadTracker = new BeatmapDownloadTracker(beatmapSet); } [BackgroundDependencyLoader(true)] private void load(BeatmapSetOverlay? beatmapSetOverlay) { - Width = width; - Height = height; - CornerRadius = corner_radius; - Masking = true; + Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID); - FillFlowContainer leftIconArea; - GridContainer titleContainer; - GridContainer artistContainer; - - InternalChildren = new Drawable[] - { - downloadTracker, - rightAreaBackground = new Container - { - RelativeSizeAxes = Axes.Y, - Width = icon_area_width + 2 * corner_radius, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - // workaround for masking artifacts at the top & bottom of card, - // which become especially visible on downloaded beatmaps (when the icon area has a lime background). - Padding = new MarginPadding { Vertical = 1 }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.White - }, - }, - thumbnail = new BeatmapCardThumbnail(beatmapSet) - { - Name = @"Left (icon) area", - Size = new Vector2(height), - Padding = new MarginPadding { Right = corner_radius }, - Child = leftIconArea = new FillFlowContainer - { - Margin = new MarginPadding(5), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(1) - } - }, - new Container - { - Name = @"Right (button) area", - Width = 30, - RelativeSizeAxes = Axes.Y, - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Padding = new MarginPadding { Vertical = 17.5f }, - Child = rightAreaButtons = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new BeatmapCardIconButton[] - { - new FavouriteButton(beatmapSet) - { - Current = favouriteState, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - new DownloadButton(beatmapSet) - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State } - }, - new GoToBeatmapButton(beatmapSet) - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State } - } - } - } - }, - mainContent = new Container - { - Name = @"Main content", - X = height - corner_radius, - Height = height, - CornerRadius = corner_radius, - Masking = true, - Children = new Drawable[] - { - mainContentBackground = new BeatmapCardContentBackground(beatmapSet) - { - RelativeSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = 10, - Vertical = 4 - }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - titleContainer = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new[] - { - new OsuSpriteText - { - Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), - Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - Truncate = true - }, - Empty() - } - } - }, - artistContainer = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new[] - { - new OsuSpriteText - { - Text = createArtistText(), - Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - Truncate = true - }, - Empty() - }, - } - }, - new LinkFlowContainer(s => - { - s.Shadow = false; - s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); - }).With(d => - { - d.AutoSizeAxes = Axes.Both; - d.Margin = new MarginPadding { Top = 2 }; - d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); - d.AddUserLink(beatmapSet.Author); - }), - } - }, - new Container - { - Name = @"Bottom content", - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Padding = new MarginPadding - { - Horizontal = 10, - Vertical = 4 - }, - Children = new Drawable[] - { - idleBottomContent = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 3), - AlwaysPresent = true, - Children = new Drawable[] - { - statisticsContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Alpha = 0, - AlwaysPresent = true, - ChildrenEnumerable = createStatistics() - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4, 0), - Children = new Drawable[] - { - new BeatmapSetOnlineStatusPill - { - AutoSizeAxes = Axes.Both, - Status = beatmapSet.Status, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - new DifficultySpectrumDisplay(beatmapSet) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - DotSize = new Vector2(6, 12) - } - } - } - } - }, - downloadProgressBar = new BeatmapCardDownloadProgressBar - { - RelativeSizeAxes = Axes.X, - Height = 6, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - State = { BindTarget = downloadTracker.State }, - Progress = { BindTarget = downloadTracker.Progress } - } - } - } - } - } - }; - - if (beatmapSet.HasVideo) - leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) }); - - if (beatmapSet.HasStoryboard) - leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) }); - - if (beatmapSet.HasExplicitContent) - { - titleContainer.Content[0][1] = new ExplicitContentBeatmapPill - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } - }; - } - - if (beatmapSet.TrackId != null) - { - artistContainer.Content[0][1] = new FeaturedArtistBeatmapPill - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } - }; - } - - Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmapSet.OnlineID); + AddInternal(DownloadTracker); } protected override void LoadComplete() { base.LoadComplete(); - downloadTracker.State.BindValueChanged(_ => updateState(), true); + DownloadTracker.State.BindValueChanged(_ => UpdateState()); + Expanded.BindValueChanged(_ => UpdateState(), true); FinishTransforms(true); } protected override bool OnHover(HoverEvent e) { - updateState(); + UpdateState(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - updateState(); + UpdateState(); base.OnHoverLost(e); } - private LocalisableString createArtistText() + protected virtual void UpdateState() { - var romanisableArtist = new RomanisableString(beatmapSet.ArtistUnicode, beatmapSet.Artist); - return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist); + bool showProgress = DownloadTracker.State.Value == DownloadState.Downloading || DownloadTracker.State.Value == DownloadState.Importing; + + IdleContent.FadeTo(showProgress ? 0 : 1, TRANSITION_DURATION, Easing.OutQuint); + DownloadInProgressContent.FadeTo(showProgress ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); } - private IEnumerable createStatistics() + /// + /// Creates a beatmap card of the given for the supplied . + /// + public static BeatmapCard Create(APIBeatmapSet beatmapSet, BeatmapCardSize size, bool allowExpansion = true) { - if (beatmapSet.HypeStatus != null) - yield return new HypesStatistic(beatmapSet.HypeStatus); - - // web does not show nominations unless hypes are also present. - // see: https://github.com/ppy/osu-web/blob/8ed7d071fd1d3eaa7e43cf0e4ff55ca2fef9c07c/resources/assets/lib/beatmapset-panel.tsx#L443 - if (beatmapSet.HypeStatus != null && beatmapSet.NominationStatus != null) - yield return new NominationsStatistic(beatmapSet.NominationStatus); - - yield return new FavouritesStatistic(beatmapSet) { Current = favouriteState }; - yield return new PlayCountStatistic(beatmapSet); - - var dateStatistic = BeatmapCardDateStatistic.CreateFor(beatmapSet); - if (dateStatistic != null) - yield return dateStatistic; - } - - private void updateState() - { - float targetWidth = width - height; - if (IsHovered) - targetWidth = targetWidth - icon_area_width + corner_radius; - - thumbnail.Dimmed.Value = IsHovered; - - mainContent.ResizeWidthTo(targetWidth, TRANSITION_DURATION, Easing.OutQuint); - mainContentBackground.Dimmed.Value = IsHovered; - - statisticsContainer.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); - - rightAreaBackground.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, TRANSITION_DURATION, Easing.OutQuint); - rightAreaButtons.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); - - foreach (var button in rightAreaButtons) + switch (size) { - button.IdleColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Light1 : colourProvider.Background3; - button.HoverColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Content1 : colourProvider.Foreground1; + case BeatmapCardSize.Normal: + return new BeatmapCardNormal(beatmapSet, allowExpansion); + + case BeatmapCardSize.Extra: + return new BeatmapCardExtra(beatmapSet, allowExpansion); + + default: + throw new ArgumentOutOfRangeException(nameof(size), size, @"Unsupported card size"); } - - bool showProgress = downloadTracker.State.Value == DownloadState.Downloading || downloadTracker.State.Value == DownloadState.Importing; - - idleBottomContent.FadeTo(showProgress ? 0 : 1, TRANSITION_DURATION, Easing.OutQuint); - downloadProgressBar.FadeTo(showProgress ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs new file mode 100644 index 0000000000..1aaa72f5f0 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs @@ -0,0 +1,157 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +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.Threading; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardContent : CompositeDrawable + { + public Drawable MainContent + { + set => bodyContent.Child = value; + } + + public Drawable ExpandedContent + { + set => dropdownScroll.Child = value; + } + + public IBindable Expanded => expanded; + + private readonly BindableBool expanded = new BindableBool(); + + private readonly Box background; + private readonly Container content; + private readonly Container bodyContent; + private readonly Container dropdownContent; + private readonly OsuScrollContainer dropdownScroll; + private readonly Container borderContainer; + + public BeatmapCardContent(float height) + { + RelativeSizeAxes = Axes.X; + Height = height; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChild = content = new HoverHandlingContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + Unhovered = _ => updateFromHoverChange(), + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + bodyContent = new Container + { + RelativeSizeAxes = Axes.X, + Height = height, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + }, + dropdownContent = new HoverHandlingContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = height }, + Alpha = 0, + Hovered = _ => + { + updateFromHoverChange(); + return true; + }, + Unhovered = _ => updateFromHoverChange(), + Child = dropdownScroll = new ExpandedContentScrollContainer + { + RelativeSizeAxes = Axes.X, + ScrollbarVisible = false + } + }, + borderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + BorderThickness = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background2; + borderContainer.BorderColour = colourProvider.Highlight1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Expanded.BindValueChanged(_ => updateState(), true); + FinishTransforms(true); + } + + private ScheduledDelegate? scheduledExpandedChange; + + public void ExpandAfterDelay() => queueExpandedStateChange(true, 100); + + public void CancelExpand() => scheduledExpandedChange?.Cancel(); + + private void updateFromHoverChange() => + queueExpandedStateChange(content.IsHovered || dropdownContent.IsHovered, 100); + + private void queueExpandedStateChange(bool newState, int delay = 0) + { + if (Expanded.Disabled) + return; + + scheduledExpandedChange?.Cancel(); + scheduledExpandedChange = Scheduler.AddDelayed(() => expanded.Value = newState, delay); + } + + private void updateState() + { + // Scale value is intentionally chosen to fit in the spacing of listing displays, as to not overlap horizontally with adjacent cards. + // This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left. + this.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint); + + background.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + dropdownContent.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + borderContainer.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + + content.TweenEdgeEffectTo(new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 2), + Radius = 10, + Colour = Colour4.Black.Opacity(Expanded.Value ? 0.3f : 0f), + Hollow = true, + }, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs new file mode 100644 index 0000000000..535f222228 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs @@ -0,0 +1,316 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +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.Beatmaps.Drawables.Cards.Statistics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapSet; +using osuTK; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardExtra : BeatmapCard + { + protected override Drawable IdleContent => idleBottomContent; + protected override Drawable DownloadInProgressContent => downloadProgressBar; + + private const float height = 140; + + [Cached] + private readonly BeatmapCardContent content; + + private BeatmapCardThumbnail thumbnail = null!; + private CollapsibleButtonContainer buttonContainer = null!; + + private GridContainer statisticsContainer = null!; + + private FillFlowContainer idleBottomContent = null!; + private BeatmapCardDownloadProgressBar downloadProgressBar = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public BeatmapCardExtra(APIBeatmapSet beatmapSet, bool allowExpansion = true) + : base(beatmapSet, allowExpansion) + { + content = new BeatmapCardContent(height); + } + + [BackgroundDependencyLoader(true)] + private void load(BeatmapSetOverlay? beatmapSetOverlay) + { + Width = WIDTH; + Height = height; + + FillFlowContainer leftIconArea = null!; + GridContainer titleContainer = null!; + GridContainer artistContainer = null!; + + Child = content.With(c => + { + c.MainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + thumbnail = new BeatmapCardThumbnail(BeatmapSet) + { + Name = @"Left (icon) area", + Size = new Vector2(height), + Padding = new MarginPadding { Right = CORNER_RADIUS }, + Child = leftIconArea = new FillFlowContainer + { + Margin = new MarginPadding(5), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1) + } + }, + buttonContainer = new CollapsibleButtonContainer(BeatmapSet) + { + X = height - CORNER_RADIUS, + Width = WIDTH - height + CORNER_RADIUS, + FavouriteState = { BindTarget = FavouriteState }, + ButtonsCollapsedWidth = CORNER_RADIUS, + ButtonsExpandedWidth = 30, + ButtonsPadding = new MarginPadding { Vertical = 35 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + titleContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), + Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true + }, + Empty() + } + } + }, + artistContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Text = createArtistText(), + Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true + }, + Empty() + }, + } + }, + new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Truncate = true, + Text = BeatmapSet.Source, + Shadow = false, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + Colour = colourProvider.Content2 + }, + } + }, + new Container + { + Name = @"Bottom content", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Children = new Drawable[] + { + idleBottomContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3), + AlwaysPresent = true, + Children = new Drawable[] + { + new LinkFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Margin = new MarginPadding { Top = 2 }; + d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); + d.AddUserLink(BeatmapSet.Author); + }), + statisticsContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[3], + new Drawable[3] + } + }, + new BeatmapCardExtraInfoRow(BeatmapSet) + } + }, + downloadProgressBar = new BeatmapCardDownloadProgressBar + { + RelativeSizeAxes = Axes.X, + Height = 6, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { BindTarget = DownloadTracker.State }, + Progress = { BindTarget = DownloadTracker.Progress } + } + } + } + } + } + } + }; + c.ExpandedContent = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Vertical = 13 }, + Child = new BeatmapCardDifficultyList(BeatmapSet) + }; + c.Expanded.BindTarget = Expanded; + }); + + if (BeatmapSet.HasVideo) + leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) }); + + if (BeatmapSet.HasStoryboard) + leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) }); + + if (BeatmapSet.HasExplicitContent) + { + titleContainer.Content[0][1] = new ExplicitContentBeatmapPill + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 5 } + }; + } + + if (BeatmapSet.TrackId != null) + { + artistContainer.Content[0][1] = new FeaturedArtistBeatmapPill + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 5 } + }; + } + + createStatistics(); + + Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID); + } + + private LocalisableString createArtistText() + { + var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist); + return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist); + } + + private void createStatistics() + { + BeatmapCardStatistic withMargin(BeatmapCardStatistic original) + { + original.Margin = new MarginPadding { Right = 10 }; + return original; + } + + statisticsContainer.Content[0][0] = withMargin(new FavouritesStatistic(BeatmapSet) + { + Current = FavouriteState, + }); + + statisticsContainer.Content[1][0] = withMargin(new PlayCountStatistic(BeatmapSet)); + + var hypesStatistic = HypesStatistic.CreateFor(BeatmapSet); + if (hypesStatistic != null) + statisticsContainer.Content[0][1] = withMargin(hypesStatistic); + + var nominationsStatistic = NominationsStatistic.CreateFor(BeatmapSet); + if (nominationsStatistic != null) + statisticsContainer.Content[1][1] = withMargin(nominationsStatistic); + + var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet); + if (dateStatistic != null) + statisticsContainer.Content[0][2] = withMargin(dateStatistic); + } + + protected override void UpdateState() + { + base.UpdateState(); + + bool showDetails = IsHovered || Expanded.Value; + + buttonContainer.ShowDetails.Value = showDetails; + thumbnail.Dimmed.Value = showDetails; + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs new file mode 100644 index 0000000000..2d411ad344 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.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. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardExtraInfoRow : CompositeDrawable + { + [Resolved(CanBeNull = true)] + private BeatmapCardContent? content { get; set; } + + public BeatmapCardExtraInfoRow(APIBeatmapSet beatmapSet) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] + { + new BeatmapSetOnlineStatusPill + { + AutoSizeAxes = Axes.Both, + Status = beatmapSet.Status, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new DifficultySpectrumDisplay(beatmapSet) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + DotSize = new Vector2(6, 12) + } + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + content?.ExpandAfterDelay(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + if (content?.Expanded.Value == false) + content.CancelExpand(); + + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs new file mode 100644 index 0000000000..08befd5340 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -0,0 +1,285 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System.Collections.Generic; +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.Beatmaps.Drawables.Cards.Statistics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapSet; +using osuTK; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardNormal : BeatmapCard + { + protected override Drawable IdleContent => idleBottomContent; + protected override Drawable DownloadInProgressContent => downloadProgressBar; + + private const float height = 100; + + [Cached] + private readonly BeatmapCardContent content; + + private BeatmapCardThumbnail thumbnail = null!; + private CollapsibleButtonContainer buttonContainer = null!; + + private FillFlowContainer statisticsContainer = null!; + + private FillFlowContainer idleBottomContent = null!; + private BeatmapCardDownloadProgressBar downloadProgressBar = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public BeatmapCardNormal(APIBeatmapSet beatmapSet, bool allowExpansion = true) + : base(beatmapSet, allowExpansion) + { + content = new BeatmapCardContent(height); + } + + [BackgroundDependencyLoader] + private void load() + { + Width = WIDTH; + Height = height; + + FillFlowContainer leftIconArea = null!; + GridContainer titleContainer = null!; + GridContainer artistContainer = null!; + + Child = content.With(c => + { + c.MainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + thumbnail = new BeatmapCardThumbnail(BeatmapSet) + { + Name = @"Left (icon) area", + Size = new Vector2(height), + Padding = new MarginPadding { Right = CORNER_RADIUS }, + Child = leftIconArea = new FillFlowContainer + { + Margin = new MarginPadding(5), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1) + } + }, + buttonContainer = new CollapsibleButtonContainer(BeatmapSet) + { + X = height - CORNER_RADIUS, + Width = WIDTH - height + CORNER_RADIUS, + FavouriteState = { BindTarget = FavouriteState }, + ButtonsCollapsedWidth = CORNER_RADIUS, + ButtonsExpandedWidth = 30, + ButtonsPadding = new MarginPadding { Vertical = 17.5f }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + titleContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), + Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true + }, + Empty() + } + } + }, + artistContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Text = createArtistText(), + Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true + }, + Empty() + }, + } + }, + new LinkFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Margin = new MarginPadding { Top = 2 }; + d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); + d.AddUserLink(BeatmapSet.Author); + }), + } + }, + new Container + { + Name = @"Bottom content", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Children = new Drawable[] + { + idleBottomContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3), + AlwaysPresent = true, + Children = new Drawable[] + { + statisticsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Alpha = 0, + AlwaysPresent = true, + ChildrenEnumerable = createStatistics() + }, + new BeatmapCardExtraInfoRow(BeatmapSet) + } + }, + downloadProgressBar = new BeatmapCardDownloadProgressBar + { + RelativeSizeAxes = Axes.X, + Height = 6, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { BindTarget = DownloadTracker.State }, + Progress = { BindTarget = DownloadTracker.Progress } + } + } + } + } + } + } + }; + c.ExpandedContent = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Vertical = 13 }, + Child = new BeatmapCardDifficultyList(BeatmapSet) + }; + c.Expanded.BindTarget = Expanded; + }); + + if (BeatmapSet.HasVideo) + leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) }); + + if (BeatmapSet.HasStoryboard) + leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) }); + + if (BeatmapSet.HasExplicitContent) + { + titleContainer.Content[0][1] = new ExplicitContentBeatmapPill + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 5 } + }; + } + + if (BeatmapSet.TrackId != null) + { + artistContainer.Content[0][1] = new FeaturedArtistBeatmapPill + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 5 } + }; + } + } + + private LocalisableString createArtistText() + { + var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist); + return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist); + } + + private IEnumerable createStatistics() + { + var hypesStatistic = HypesStatistic.CreateFor(BeatmapSet); + if (hypesStatistic != null) + yield return hypesStatistic; + + var nominationsStatistic = NominationsStatistic.CreateFor(BeatmapSet); + if (nominationsStatistic != null) + yield return nominationsStatistic; + + yield return new FavouritesStatistic(BeatmapSet) { Current = FavouriteState }; + yield return new PlayCountStatistic(BeatmapSet); + + var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet); + if (dateStatistic != null) + yield return dateStatistic; + } + + protected override void UpdateState() + { + base.UpdateState(); + + bool showDetails = IsHovered || Expanded.Value; + + buttonContainer.ShowDetails.Value = showDetails; + thumbnail.Dimmed.Value = showDetails; + + statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs new file mode 100644 index 0000000000..098265506d --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs @@ -0,0 +1,14 @@ +// 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.Drawables.Cards +{ + /// + /// Enumeration for all available sizes of . + /// + public enum BeatmapCardSize + { + Normal, + Extra + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs new file mode 100644 index 0000000000..3a2cb80a8d --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs @@ -0,0 +1,184 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +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.Beatmaps.Drawables.Cards.Buttons; +using osu.Game.Graphics; +using osu.Game.Online; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class CollapsibleButtonContainer : Container + { + public Bindable ShowDetails = new Bindable(); + public Bindable FavouriteState = new Bindable(); + + private readonly BeatmapDownloadTracker downloadTracker; + + private float buttonsExpandedWidth; + + public float ButtonsExpandedWidth + { + get => buttonsExpandedWidth; + set + { + buttonsExpandedWidth = value; + buttonArea.Width = value; + if (IsLoaded) + updateState(); + } + } + + private float buttonsCollapsedWidth; + + public float ButtonsCollapsedWidth + { + get => buttonsCollapsedWidth; + set + { + buttonsCollapsedWidth = value; + if (IsLoaded) + updateState(); + } + } + + public MarginPadding ButtonsPadding + { + get => buttons.Padding; + set => buttons.Padding = value; + } + + protected override Container Content => mainContent; + + private readonly Container background; + + private readonly Container buttonArea; + private readonly Container buttons; + + private readonly Container mainArea; + private readonly Container mainContent; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public CollapsibleButtonContainer(APIBeatmapSet beatmapSet) + { + downloadTracker = new BeatmapDownloadTracker(beatmapSet); + + RelativeSizeAxes = Axes.Y; + Masking = true; + CornerRadius = BeatmapCard.CORNER_RADIUS; + + InternalChildren = new Drawable[] + { + downloadTracker, + background = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + // workaround for masking artifacts at the top & bottom of card, + // which become especially visible on downloaded beatmaps (when the icon area has a lime background). + Padding = new MarginPadding { Vertical = 1 }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White + }, + }, + buttonArea = new Container + { + Name = @"Right (button) area", + RelativeSizeAxes = Axes.Y, + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + Child = buttons = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new BeatmapCardIconButton[] + { + new FavouriteButton(beatmapSet) + { + Current = FavouriteState, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + new DownloadButton(beatmapSet) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + State = { BindTarget = downloadTracker.State } + }, + new GoToBeatmapButton(beatmapSet) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + State = { BindTarget = downloadTracker.State } + } + } + } + }, + mainArea = new Container + { + Name = @"Main content", + RelativeSizeAxes = Axes.Y, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + new BeatmapCardContentBackground(beatmapSet) + { + RelativeSizeAxes = Axes.Both, + Dimmed = { BindTarget = ShowDetails } + }, + mainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 10, + Vertical = 4 + }, + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + downloadTracker.State.BindValueChanged(_ => updateState()); + ShowDetails.BindValueChanged(_ => updateState(), true); + FinishTransforms(true); + } + + private void updateState() + { + float targetWidth = Width - (ShowDetails.Value ? ButtonsExpandedWidth : ButtonsCollapsedWidth); + + mainArea.ResizeWidthTo(targetWidth, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + + background.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + buttons.FadeTo(ShowDetails.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + + foreach (var button in buttons) + { + button.IdleColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Light1 : colourProvider.Background3; + button.HoverColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Content1 : colourProvider.Foreground1; + } + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs new file mode 100644 index 0000000000..edf4c5328c --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.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.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class ExpandedContentScrollContainer : OsuScrollContainer + { + public const float HEIGHT = 200; + + public ExpandedContentScrollContainer() + { + ScrollbarVisible = false; + } + + protected override void Update() + { + base.Update(); + + Height = Math.Min(Content.DrawHeight, HEIGHT); + } + + private bool allowScroll => !Precision.AlmostEquals(DrawSize, Content.DrawSize); + + protected override bool OnDragStart(DragStartEvent e) + { + if (!allowScroll) + return false; + + return base.OnDragStart(e); + } + + protected override void OnDrag(DragEvent e) + { + if (!allowScroll) + return; + + base.OnDrag(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (!allowScroll) + return; + + base.OnDragEnd(e); + } + + protected override bool OnScroll(ScrollEvent e) + { + if (!allowScroll) + return false; + + return base.OnScroll(e); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs new file mode 100644 index 0000000000..fe37616755 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.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. + +#nullable enable + +using System; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class HoverHandlingContainer : Container + { + public Func? Hovered { get; set; } + public Action? Unhovered { get; set; } + + protected override bool OnHover(HoverEvent e) + { + bool handledByBase = base.OnHover(e); + return Hovered?.Invoke(e) ?? handledByBase; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + Unhovered?.Invoke(e); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.cs index 3fe31c7a41..521d1a5f21 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.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. +#nullable enable + using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; @@ -12,11 +14,14 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// public class HypesStatistic : BeatmapCardStatistic { - public HypesStatistic(BeatmapSetHypeStatus hypeStatus) + private HypesStatistic(BeatmapSetHypeStatus hypeStatus) { Icon = FontAwesome.Solid.Bullhorn; Text = hypeStatus.Current.ToLocalisableString(); TooltipText = BeatmapsStrings.HypeRequiredText(hypeStatus.Current.ToLocalisableString(), hypeStatus.Required.ToLocalisableString()); } + + public static HypesStatistic? CreateFor(IBeatmapSetOnlineInfo beatmapSetOnlineInfo) + => beatmapSetOnlineInfo.HypeStatus == null ? null : new HypesStatistic(beatmapSetOnlineInfo.HypeStatus); } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs index f09269a615..23bd6ef0a9 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.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. +#nullable enable + using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; @@ -12,11 +14,16 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics ///
public class NominationsStatistic : BeatmapCardStatistic { - public NominationsStatistic(BeatmapSetNominationStatus nominationStatus) + private NominationsStatistic(BeatmapSetNominationStatus nominationStatus) { Icon = FontAwesome.Solid.ThumbsUp; Text = nominationStatus.Current.ToLocalisableString(); TooltipText = BeatmapsStrings.NominationsRequiredText(nominationStatus.Current.ToLocalisableString(), nominationStatus.Required.ToLocalisableString()); } + + public static NominationsStatistic? CreateFor(IBeatmapSetOnlineInfo beatmapSetOnlineInfo) + // web does not show nominations unless hypes are also present. + // see: https://github.com/ppy/osu-web/blob/8ed7d071fd1d3eaa7e43cf0e4ff55ca2fef9c07c/resources/assets/lib/beatmapset-panel.tsx#L443 + => beatmapSetOnlineInfo.HypeStatus == null || beatmapSetOnlineInfo.NominationStatus == null ? null : new NominationsStatistic(beatmapSetOnlineInfo.NominationStatus); } } diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 6e573cc2a0..82be0559a7 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -86,7 +86,7 @@ namespace osu.Game.Beatmaps.Drawables } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs new file mode 100644 index 0000000000..8c915e2872 --- /dev/null +++ b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.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.IO; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Skinning; + +namespace osu.Game.Beatmaps +{ + /// + /// A which can be constructed directly from a .osu file, providing an implementation for + /// . + /// + public class FlatFileWorkingBeatmap : WorkingBeatmap + { + private readonly Beatmap beatmap; + + public FlatFileWorkingBeatmap(string file, Func rulesetProvider, int? beatmapId = null) + : this(readFromFile(file), rulesetProvider, beatmapId) + { + } + + private FlatFileWorkingBeatmap(Beatmap beatmap, Func rulesetProvider, int? beatmapId = null) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + + beatmap.BeatmapInfo.Ruleset = rulesetProvider(beatmap.BeatmapInfo.RulesetID).RulesetInfo; + + if (beatmapId.HasValue) + beatmap.BeatmapInfo.OnlineID = beatmapId; + } + + private static Beatmap readFromFile(string filename) + { + using (var stream = File.OpenRead(filename)) + using (var reader = new LineBufferedReader(stream)) + return Decoder.GetDecoder(reader).Decode(reader); + } + + protected override IBeatmap GetBeatmap() => beatmap; + protected override Texture GetBackground() => throw new NotImplementedException(); + protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + protected internal override ISkin GetSkin() => throw new NotImplementedException(); + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); + } +} diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index 717162fd7f..3e2868470a 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -15,39 +15,18 @@ using osu.Game.Storyboards; namespace osu.Game.Beatmaps { + /// + /// Provides access to the multiple resources offered by a beatmap model (textures, skins, playable beatmaps etc.) + /// public interface IWorkingBeatmap { IBeatmapInfo BeatmapInfo { get; } - IBeatmapSetInfo BeatmapSetInfo { get; } - - IBeatmapMetadataInfo Metadata { get; } - /// /// Whether the Beatmap has finished loading. /// public bool BeatmapLoaded { get; } - /// - /// Whether the Background has finished loading. - /// - public bool BackgroundLoaded { get; } - - /// - /// Whether the Waveform has finished loading. - /// - public bool WaveformLoaded { get; } - - /// - /// Whether the Storyboard has finished loading. - /// - public bool StoryboardLoaded { get; } - - /// - /// Whether the Skin has finished loading. - /// - public bool SkinLoaded { get; } - /// /// Whether the Track has finished loading. /// diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index a17f247d9b..8289b32d31 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Extensions; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Testing; @@ -28,24 +29,126 @@ namespace osu.Game.Beatmaps { public readonly BeatmapInfo BeatmapInfo; public readonly BeatmapSetInfo BeatmapSetInfo; - public readonly BeatmapMetadata Metadata; - protected AudioManager AudioManager { get; } + // TODO: remove once the fallback lookup is not required (and access via `working.BeatmapInfo.Metadata` directly). + public BeatmapMetadata Metadata => BeatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); + + public Waveform Waveform => waveform.Value; + + public Storyboard Storyboard => storyboard.Value; + + public Texture Background => GetBackground(); // Texture uses ref counting, so we want to return a new instance every usage. + + public ISkin Skin => skin.Value; + + private AudioManager audioManager { get; } + + private CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); + + private readonly object beatmapFetchLock = new object(); + + private readonly Lazy waveform; + private readonly Lazy storyboard; + private readonly Lazy skin; + private Track track; // track is not Lazy as we allow transferring and loading multiple times. protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { - AudioManager = audioManager; + this.audioManager = audioManager; + BeatmapInfo = beatmapInfo; BeatmapSetInfo = beatmapInfo.BeatmapSet; - Metadata = beatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); - background = new RecyclableLazy(GetBackground, BackgroundStillValid); - waveform = new RecyclableLazy(GetWaveform); - storyboard = new RecyclableLazy(GetStoryboard); - skin = new RecyclableLazy(GetSkin); + waveform = new Lazy(GetWaveform); + storyboard = new Lazy(GetStoryboard); + skin = new Lazy(GetSkin); } - protected virtual Track GetVirtualTrack(double emptyLength = 0) + #region Resource getters + + protected virtual Waveform GetWaveform() => new Waveform(null); + protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; + + protected abstract IBeatmap GetBeatmap(); + protected abstract Texture GetBackground(); + protected abstract Track GetBeatmapTrack(); + + /// + /// Creates a new skin instance for this beatmap. + /// + /// + /// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin + /// (e.g. for editing purposes, to avoid state pollution). + /// For standard reading purposes, should always be used directly. + /// + protected internal abstract ISkin GetSkin(); + + #endregion + + #region Async load control + + public void BeginAsyncLoad() => loadBeatmapAsync(); + + public void CancelAsyncLoad() + { + lock (beatmapFetchLock) + { + loadCancellationSource?.Cancel(); + loadCancellationSource = new CancellationTokenSource(); + + if (beatmapLoadTask?.IsCompleted != true) + beatmapLoadTask = null; + } + } + + #endregion + + #region Track + + public virtual bool TrackLoaded => track != null; + + public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000); + + public void PrepareTrackForPreviewLooping() + { + Track.Looping = true; + Track.RestartPoint = Metadata.PreviewTime; + + if (Track.RestartPoint == -1) + { + if (!Track.IsLoaded) + { + // force length to be populated (https://github.com/ppy/osu-framework/issues/4202) + Track.Seek(Track.CurrentTime); + } + + Track.RestartPoint = 0.4f * Track.Length; + } + } + + /// + /// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap + /// across difficulties in the same beatmap set. + /// + /// The track to transfer. + public void TransferTrack([NotNull] Track track) => this.track = track ?? throw new ArgumentNullException(nameof(track)); + + /// + /// Get the loaded audio track instance. must have first been called. + /// This generally happens via MusicController when changing the global beatmap. + /// + public Track Track + { + get + { + if (!TrackLoaded) + throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}."); + + return track; + } + } + + protected Track GetVirtualTrack(double emptyLength = 0) { const double excess_length = 1000; @@ -68,18 +171,67 @@ namespace osu.Game.Beatmaps break; } - return AudioManager.Tracks.GetVirtual(length); + return audioManager.Tracks.GetVirtual(length); } - /// - /// Creates a to convert a for a specified . - /// - /// The to be converted. - /// The for which should be converted. - /// The applicable . - protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); + #endregion - public IBeatmap GetPlayableBeatmap([NotNull] IRulesetInfo ruleset, IReadOnlyList mods = null) + #region Beatmap + + public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; + + public IBeatmap Beatmap + { + get + { + try + { + return loadBeatmapAsync().GetResultSafely(); + } + catch (AggregateException ae) + { + // This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load + if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) + return null; + + Logger.Error(ae, "Beatmap failed to load"); + return null; + } + catch (Exception e) + { + Logger.Error(e, "Beatmap failed to load"); + return null; + } + } + } + + private Task beatmapLoadTask; + + private Task loadBeatmapAsync() + { + lock (beatmapFetchLock) + { + return beatmapLoadTask ??= Task.Factory.StartNew(() => + { + // Todo: Handle cancellation during beatmap parsing + var b = GetBeatmap() ?? new Beatmap(); + + // The original beatmap version needs to be preserved as the database doesn't contain it + BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; + + // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) + b.BeatmapInfo = BeatmapInfo; + + return b; + }, loadCancellationSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + } + + #endregion + + #region Playable beatmap + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods = null) { try { @@ -95,7 +247,7 @@ namespace osu.Game.Beatmaps } } - public virtual IBeatmap GetPlayableBeatmap([NotNull] IRulesetInfo ruleset, [NotNull] IReadOnlyList mods, CancellationToken token) + public virtual IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods, CancellationToken token) { var rulesetInstance = ruleset.CreateInstance(); @@ -169,207 +321,21 @@ namespace osu.Game.Beatmaps return converted; } - private CancellationTokenSource loadCancellation = new CancellationTokenSource(); + /// + /// Creates a to convert a for a specified . + /// + /// The to be converted. + /// The for which should be converted. + /// The applicable . + protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); - public void BeginAsyncLoad() => loadBeatmapAsync(); - - public void CancelAsyncLoad() - { - lock (beatmapFetchLock) - { - loadCancellation?.Cancel(); - loadCancellation = new CancellationTokenSource(); - - if (beatmapLoadTask?.IsCompleted != true) - beatmapLoadTask = null; - } - } - - private readonly object beatmapFetchLock = new object(); - - private Task loadBeatmapAsync() - { - lock (beatmapFetchLock) - { - return beatmapLoadTask ??= Task.Factory.StartNew(() => - { - // Todo: Handle cancellation during beatmap parsing - var b = GetBeatmap() ?? new Beatmap(); - - // The original beatmap version needs to be preserved as the database doesn't contain it - BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; - - // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) - b.BeatmapInfo = BeatmapInfo; - - return b; - }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); - } - } + #endregion public override string ToString() => BeatmapInfo.ToString(); - public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; - - IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo; - IBeatmapMetadataInfo IWorkingBeatmap.Metadata => Metadata; - IBeatmapSetInfo IWorkingBeatmap.BeatmapSetInfo => BeatmapSetInfo; - - public IBeatmap Beatmap - { - get - { - try - { - return loadBeatmapAsync().Result; - } - catch (AggregateException ae) - { - // This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load - if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) - return null; - - Logger.Error(ae, "Beatmap failed to load"); - return null; - } - catch (Exception e) - { - Logger.Error(e, "Beatmap failed to load"); - return null; - } - } - } - - protected abstract IBeatmap GetBeatmap(); - private Task beatmapLoadTask; - - public bool BackgroundLoaded => background.IsResultAvailable; - public Texture Background => background.Value; - protected virtual bool BackgroundStillValid(Texture b) => b == null || b.Available; - protected abstract Texture GetBackground(); - private readonly RecyclableLazy background; - - private Track loadedTrack; - - [NotNull] - public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); - - public void PrepareTrackForPreviewLooping() - { - Track.Looping = true; - Track.RestartPoint = Metadata.PreviewTime; - - if (Track.RestartPoint == -1) - { - if (!Track.IsLoaded) - { - // force length to be populated (https://github.com/ppy/osu-framework/issues/4202) - Track.Seek(Track.CurrentTime); - } - - Track.RestartPoint = 0.4f * Track.Length; - } - } - - /// - /// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap - /// across difficulties in the same beatmap set. - /// - /// The track to transfer. - public void TransferTrack([NotNull] Track track) => loadedTrack = track ?? throw new ArgumentNullException(nameof(track)); - - /// - /// Whether this beatmap's track has been loaded via . - /// - public virtual bool TrackLoaded => loadedTrack != null; - - /// - /// Get the loaded audio track instance. must have first been called. - /// This generally happens via MusicController when changing the global beatmap. - /// - public Track Track - { - get - { - if (!TrackLoaded) - throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}."); - - return loadedTrack; - } - } - - protected abstract Track GetBeatmapTrack(); - - public bool WaveformLoaded => waveform.IsResultAvailable; - public Waveform Waveform => waveform.Value; - protected virtual Waveform GetWaveform() => new Waveform(null); - private readonly RecyclableLazy waveform; - - public bool StoryboardLoaded => storyboard.IsResultAvailable; - public Storyboard Storyboard => storyboard.Value; - protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; - private readonly RecyclableLazy storyboard; - - public bool SkinLoaded => skin.IsResultAvailable; - public ISkin Skin => skin.Value; - - /// - /// Creates a new skin instance for this beatmap. - /// - /// - /// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin - /// (e.g. for editing purposes, to avoid state pollution). - /// For standard reading purposes, should always be used directly. - /// - protected internal abstract ISkin GetSkin(); - - private readonly RecyclableLazy skin; - public abstract Stream GetStream(string storagePath); - public class RecyclableLazy - { - private Lazy lazy; - private readonly Func valueFactory; - private readonly Func stillValidFunction; - - private readonly object fetchLock = new object(); - - public RecyclableLazy(Func valueFactory, Func stillValidFunction = null) - { - this.valueFactory = valueFactory; - this.stillValidFunction = stillValidFunction; - - recreate(); - } - - public void Recycle() - { - if (!IsResultAvailable) return; - - (lazy.Value as IDisposable)?.Dispose(); - recreate(); - } - - public bool IsResultAvailable => stillValid; - - public T Value - { - get - { - lock (fetchLock) - { - if (!stillValid) - recreate(); - return lazy.Value; - } - } - } - - private bool stillValid => lazy.IsValueCreated && (stillValidFunction?.Invoke(lazy.Value) ?? true); - - private void recreate() => lazy = new Lazy(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication); - } + IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo; private class BeatmapLoadTimeoutException : TimeoutException { diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 559685d3c4..514551e184 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -15,6 +15,7 @@ using osu.Framework.Platform; using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; +using osu.Game.Database; using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -108,6 +109,7 @@ namespace osu.Game.Beatmaps TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; ITrackStore IBeatmapResourceProvider.Tracks => trackStore; AudioManager IStorageResourceProvider.AudioManager => audioManager; + RealmContextFactory IStorageResourceProvider.RealmContextFactory => null; IResourceStore IStorageResourceProvider.Files => files; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); @@ -143,8 +145,6 @@ namespace osu.Game.Beatmaps } } - protected override bool BackgroundStillValid(Texture b) => false; // bypass lazy logic. we want to return a new background each time for refcounting purposes. - protected override Texture GetBackground() { if (string.IsNullOrEmpty(Metadata?.BackgroundFile)) diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index ad23874b2e..77bda00107 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osuTK; @@ -193,9 +192,6 @@ namespace osu.Game.Collections [NotNull] protected new CollectionFilterMenuItem Item => ((DropdownMenuItem)base.Item).Value; - [Resolved] - private OsuColour colours { get; set; } - [Resolved] private IBindable beatmap { get; set; } diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 9ff92032b7..c4f991094c 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -40,9 +40,6 @@ namespace osu.Game.Collections public readonly BindableList Collections = new BindableList(); - [Resolved] - private GameHost host { get; set; } - [Resolved] private BeatmapManager beatmaps { get; set; } diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 988a3443c3..c4cb040b52 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -80,7 +80,7 @@ namespace osu.Game.Collections } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Children = new Drawable[] { @@ -102,7 +102,6 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.Both, Size = Vector2.One, CornerRadius = item_height / 2, - Current = collection.Name, PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection" }, } @@ -114,6 +113,9 @@ 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); } @@ -144,8 +146,8 @@ namespace osu.Game.Collections [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundUnfocused = colours.GreySeafoamDarker.Darken(0.5f); - BackgroundFocused = colours.GreySeafoam; + BackgroundUnfocused = colours.GreySeaFoamDarker.Darken(0.5f); + BackgroundFocused = colours.GreySeaFoam; } } diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 95fbfa0f86..cb350bca33 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -46,7 +46,7 @@ namespace osu.Game.Collections { new Box { - Colour = colours.GreySeafoamDark, + Colour = colours.GreySeaFoamDark, RelativeSizeAxes = Axes.Both, }, new Container @@ -82,7 +82,7 @@ namespace osu.Game.Collections Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Icon = FontAwesome.Solid.Times, - Colour = colours.GreySeafoamDarker, + Colour = colours.GreySeaFoamDarker, Scale = new Vector2(0.8f), X = -10, Action = () => State.Value = Visibility.Hidden @@ -100,7 +100,7 @@ namespace osu.Game.Collections new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDarker + Colour = colours.GreySeaFoamDarker }, new DrawableCollectionList { diff --git a/osu.Game/Configuration/DatabasedSetting.cs b/osu.Game/Configuration/DatabasedSetting.cs index fe1d51d57f..65d9f7799d 100644 --- a/osu.Game/Configuration/DatabasedSetting.cs +++ b/osu.Game/Configuration/DatabasedSetting.cs @@ -11,6 +11,8 @@ namespace osu.Game.Configuration { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int? RulesetID { get; set; } public int? Variant { get; set; } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 84da3f666d..07d2026c65 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -17,6 +17,7 @@ using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; +using osu.Game.Skinning; namespace osu.Game.Configuration { @@ -27,7 +28,7 @@ namespace osu.Game.Configuration { // UI/selection defaults SetDefault(OsuSetting.Ruleset, string.Empty); - SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue); + SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString()); SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); @@ -97,6 +98,8 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.MenuParallax, true); // 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.BlurLevel, 0, 0, 1, 0.01); SetDefault(OsuSetting.LightenDuringBreaks, true); @@ -108,7 +111,6 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); SetDefault(OsuSetting.KeyOverlay, false); - SetDefault(OsuSetting.PositionalHitSounds, true); SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); SetDefault(OsuSetting.FloatingComments, false); @@ -174,9 +176,11 @@ namespace osu.Game.Configuration int combined = (year * 10000) + monthDay; - if (combined < 20210413) + if (combined < 20220103) { - SetValue(OsuSetting.EditorWaveformOpacity, 0.25f); + var positionalHitsoundsEnabled = GetBindable(OsuSetting.PositionalHitsounds); + if (!positionalHitsoundsEnabled.Value) + SetValue(OsuSetting.PositionalHitsoundsLevel, 0); } } @@ -210,9 +214,12 @@ namespace osu.Game.Configuration value: scalingMode.GetLocalisableDescription() ) ), - new TrackedSetting(OsuSetting.Skin, skin => + new TrackedSetting(OsuSetting.Skin, skin => { - string skinName = LookupSkinName(skin) ?? string.Empty; + string skinName = string.Empty; + + if (Guid.TryParse(skin, out var id)) + skinName = LookupSkinName(id) ?? string.Empty; return new SettingDescription( rawValue: skinName, @@ -233,7 +240,7 @@ namespace osu.Game.Configuration }; } - public Func LookupSkinName { private get; set; } + public Func LookupSkinName { private get; set; } public Func LookupKeyBindings { get; set; } } @@ -252,7 +259,8 @@ namespace osu.Game.Configuration LightenDuringBreaks, ShowStoryboard, KeyOverlay, - PositionalHitSounds, + PositionalHitsounds, + PositionalHitsoundsLevel, AlwaysPlayFirstComboBreak, FloatingComments, HUDVisibilityMode, diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index ac94c39bd2..837ee7e634 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.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 osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -13,18 +12,27 @@ namespace osu.Game.Configuration /// public class SessionStatics : InMemoryConfigManager { - protected override void InitialiseDefaults() => ResetValues(); - - public void ResetValues() + protected override void InitialiseDefaults() { - ensureDefault(SetDefault(Static.LoginOverlayDisplayed, false)); - ensureDefault(SetDefault(Static.MutedAudioNotificationShownOnce, false)); - ensureDefault(SetDefault(Static.LowBatteryNotificationShownOnce, false)); - ensureDefault(SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null)); - ensureDefault(SetDefault(Static.SeasonalBackgrounds, null)); + SetDefault(Static.LoginOverlayDisplayed, false); + SetDefault(Static.MutedAudioNotificationShownOnce, false); + SetDefault(Static.LowBatteryNotificationShownOnce, false); + SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); + SetDefault(Static.SeasonalBackgrounds, null); } - private void ensureDefault(Bindable bindable) => bindable.SetDefault(); + /// + /// Revert statics to their defaults after being idle for appropriate amount of time. + /// + /// + /// This only affects a subset of statics which the user would expect to have reset after a break. + /// + public void ResetAfterInactivity() + { + GetBindable(Static.LoginOverlayDisplayed).SetDefault(); + GetBindable(Static.MutedAudioNotificationShownOnce).SetDefault(); + GetBindable(Static.LowBatteryNotificationShownOnce).SetDefault(); + } } public enum Static diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 8cabb55cc3..9c26451d40 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -476,7 +476,7 @@ namespace osu.Game.Database { Files.Dereference(file.FileInfo); - if (file.ID > 0) + if (file.IsManaged) { // This shouldn't be required, but here for safety in case the provided TModel is not being change tracked // Definitely can be removed once we rework the database backend. @@ -505,7 +505,7 @@ namespace osu.Game.Database }); } - if (model.ID > 0) + if (model.IsManaged) Update(model); } @@ -737,7 +737,7 @@ namespace osu.Game.Database /// The usable items present in the store. /// Whether the exists. protected virtual bool CheckLocalAvailability(TModel model, IQueryable items) - => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any()); + => model.IsManaged && items.Any(i => i.ID == model.ID && i.Files.Any()); /// /// Whether import can be skipped after finding an existing import early in the process. diff --git a/osu.Game/Database/BeatmapLookupCache.cs b/osu.Game/Database/BeatmapLookupCache.cs index c6f8244494..06edc3e2da 100644 --- a/osu.Game/Database/BeatmapLookupCache.cs +++ b/osu.Game/Database/BeatmapLookupCache.cs @@ -6,20 +6,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Database { - // This class is based on `UserLookupCache` which is well tested. - // If modifications are to be made here, a base abstract implementation should likely be created and shared between the two. - public class BeatmapLookupCache : MemoryCachingComponent + public class BeatmapLookupCache : OnlineLookupCache { - [Resolved] - private IAPIProvider api { get; set; } - /// /// Perform an API lookup on the specified beatmap, populating a model. /// @@ -27,7 +20,7 @@ namespace osu.Game.Database /// An optional cancellation token. /// The populated beatmap, or null if the beatmap does not exist or the request could not be satisfied. [ItemCanBeNull] - public Task GetBeatmapAsync(int beatmapId, CancellationToken token = default) => GetAsync(beatmapId, token); + public Task GetBeatmapAsync(int beatmapId, CancellationToken token = default) => LookupAsync(beatmapId, token); /// /// Perform an API lookup on the specified beatmaps, populating a model. @@ -35,115 +28,10 @@ namespace osu.Game.Database /// The beatmaps to lookup. /// An optional cancellation token. /// The populated beatmaps. May include null results for failed retrievals. - public Task GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default) - { - var beatmapLookupTasks = new List>(); + public Task GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default) => LookupAsync(beatmapIds, token); - foreach (int u in beatmapIds) - { - beatmapLookupTasks.Add(GetBeatmapAsync(u, token).ContinueWith(task => - { - if (!task.IsCompletedSuccessfully) - return null; + protected override GetBeatmapsRequest CreateRequest(IEnumerable ids) => new GetBeatmapsRequest(ids.ToArray()); - return task.Result; - }, token)); - } - - return Task.WhenAll(beatmapLookupTasks); - } - - protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) - => await queryBeatmap(lookup).ConfigureAwait(false); - - private readonly Queue<(int id, TaskCompletionSource)> pendingBeatmapTasks = new Queue<(int, TaskCompletionSource)>(); - private Task pendingRequestTask; - private readonly object taskAssignmentLock = new object(); - - private Task queryBeatmap(int beatmapId) - { - lock (taskAssignmentLock) - { - var tcs = new TaskCompletionSource(); - - // Add to the queue. - pendingBeatmapTasks.Enqueue((beatmapId, tcs)); - - // Create a request task if there's not already one. - if (pendingRequestTask == null) - createNewTask(); - - return tcs.Task; - } - } - - private void performLookup() - { - // contains at most 50 unique beatmap IDs from beatmapTasks, which is used to perform the lookup. - var beatmapTasks = new Dictionary>>(); - - // Grab at most 50 unique beatmap IDs from the queue. - lock (taskAssignmentLock) - { - while (pendingBeatmapTasks.Count > 0 && beatmapTasks.Count < 50) - { - (int id, TaskCompletionSource task) next = pendingBeatmapTasks.Dequeue(); - - // Perform a secondary check for existence, in case the beatmap was queried in a previous batch. - if (CheckExists(next.id, out var existing)) - next.task.SetResult(existing); - else - { - if (beatmapTasks.TryGetValue(next.id, out var tasks)) - tasks.Add(next.task); - else - beatmapTasks[next.id] = new List> { next.task }; - } - } - } - - if (beatmapTasks.Count == 0) - return; - - // Query the beatmaps. - var request = new GetBeatmapsRequest(beatmapTasks.Keys.ToArray()); - - // rather than queueing, we maintain our own single-threaded request stream. - // todo: we probably want retry logic here. - api.Perform(request); - - // Create a new request task if there's still more beatmaps to query. - lock (taskAssignmentLock) - { - pendingRequestTask = null; - if (pendingBeatmapTasks.Count > 0) - createNewTask(); - } - - List foundBeatmaps = request.Response?.Beatmaps; - - if (foundBeatmaps != null) - { - foreach (var beatmap in foundBeatmaps) - { - if (beatmapTasks.TryGetValue(beatmap.OnlineID, out var tasks)) - { - foreach (var task in tasks) - task.SetResult(beatmap); - - beatmapTasks.Remove(beatmap.OnlineID); - } - } - } - - // if any tasks remain which were not satisfied, return null. - foreach (var tasks in beatmapTasks.Values) - { - foreach (var task in tasks) - task.SetResult(null); - } - } - - private void createNewTask() => pendingRequestTask = Task.Run(performLookup); + protected override IEnumerable RetrieveResults(GetBeatmapsRequest request) => request.Response?.Beatmaps; } } diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs new file mode 100644 index 0000000000..b79a982460 --- /dev/null +++ b/osu.Game/Database/EFToRealmMigrator.cs @@ -0,0 +1,148 @@ +// 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 Microsoft.EntityFrameworkCore; +using osu.Game.Configuration; +using osu.Game.Models; +using osu.Game.Skinning; + +#nullable enable + +namespace osu.Game.Database +{ + internal class EFToRealmMigrator + { + private readonly DatabaseContextFactory efContextFactory; + private readonly RealmContextFactory realmContextFactory; + private readonly OsuConfigManager config; + + public EFToRealmMigrator(DatabaseContextFactory efContextFactory, RealmContextFactory realmContextFactory, OsuConfigManager config) + { + this.efContextFactory = efContextFactory; + this.realmContextFactory = realmContextFactory; + this.config = config; + } + + public void Run() + { + using (var db = efContextFactory.GetForWrite()) + { + migrateSettings(db); + migrateSkins(db); + } + } + + private void migrateSkins(DatabaseWriteUsage db) + { + // can be removed 20220530. + var existingSkins = db.Context.SkinInfo + .Include(s => s.Files) + .ThenInclude(f => f.FileInfo) + .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; + } + + using (var realm = realmContextFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) + { + // only migrate data if the realm database is empty. + // note that this cannot be written as: `realm.All().All(s => s.Protected)`, because realm does not support `.All()`. + if (!realm.All().Any(s => !s.Protected)) + { + foreach (var skin in existingSkins) + { + var realmSkin = new SkinInfo + { + Name = skin.Name, + Creator = skin.Creator, + Hash = skin.Hash, + Protected = false, + InstantiationInfo = skin.InstantiationInfo, + }; + + foreach (var file in skin.Files) + { + var realmFile = realm.Find(file.FileInfo.Hash); + + if (realmFile == null) + realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash }); + + realmSkin.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename)); + } + + realm.Add(realmSkin); + + if (skin.ID == userSkinInt) + userSkinChoice.Value = realmSkin.ID.ToString(); + } + } + + db.Context.RemoveRange(existingSkins); + // Intentionally don't clean up the files, so they don't get purged by EF. + + transaction.Commit(); + } + } + + private void migrateSettings(DatabaseWriteUsage db) + { + // migrate ruleset settings. can be removed 20220315. + var existingSettings = db.Context.DatabasedSetting; + + // previous entries in EF are removed post migration. + if (!existingSettings.Any()) + return; + + using (var realm = realmContextFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) + { + // only migrate data if the realm database is empty. + if (!realm.All().Any()) + { + foreach (var dkb in existingSettings) + { + if (dkb.RulesetID == null) + continue; + + string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value); + + if (string.IsNullOrEmpty(shortName)) + continue; + + realm.Add(new RealmRulesetSetting + { + Key = dkb.Key, + Value = dkb.StringValue, + RulesetName = shortName, + Variant = dkb.Variant ?? 0, + }); + } + } + + db.Context.RemoveRange(existingSettings); + + transaction.Commit(); + } + } + + private string? getRulesetShortNameFromLegacyID(long rulesetId) => + efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; + } +} diff --git a/osu.Game/Database/IHasPrimaryKey.cs b/osu.Game/Database/IHasPrimaryKey.cs index 3c0fc94418..51a49948fe 100644 --- a/osu.Game/Database/IHasPrimaryKey.cs +++ b/osu.Game/Database/IHasPrimaryKey.cs @@ -11,5 +11,7 @@ namespace osu.Game.Database [JsonIgnore] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] int ID { get; set; } + + bool IsManaged { get; } } } diff --git a/osu.Game/Database/IModelFileManager.cs b/osu.Game/Database/IModelFileManager.cs index 4bc1e2d29b..390be4a69d 100644 --- a/osu.Game/Database/IModelFileManager.cs +++ b/osu.Game/Database/IModelFileManager.cs @@ -25,7 +25,7 @@ namespace osu.Game.Database void DeleteFile(TModel model, TFileModel file); /// - /// Add a new file. + /// Add a new file. If the file already exists, it is overwritten. /// /// The item to operate on. /// The new file contents. diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs new file mode 100644 index 0000000000..2f98aef58a --- /dev/null +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -0,0 +1,171 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Game.Online.API; + +namespace osu.Game.Database +{ + public abstract class OnlineLookupCache : MemoryCachingComponent + where TLookup : IEquatable + where TValue : class, IHasOnlineID + where TRequest : APIRequest + { + [Resolved] + private IAPIProvider api { get; set; } + + /// + /// Creates an to retrieve the values for a given collection of s. + /// + /// The IDs to perform the lookup with. + protected abstract TRequest CreateRequest(IEnumerable ids); + + /// + /// Retrieves a list of s from a successful created by . + /// + [CanBeNull] + protected abstract IEnumerable RetrieveResults(TRequest request); + + /// + /// Perform a lookup using the specified , populating a . + /// + /// The ID to lookup. + /// An optional cancellation token. + /// The populated , or null if the value does not exist or the request could not be satisfied. + [ItemCanBeNull] + protected Task LookupAsync(TLookup id, CancellationToken token = default) => GetAsync(id, token); + + /// + /// Perform an API lookup on the specified , populating a . + /// + /// The IDs to lookup. + /// An optional cancellation token. + /// The populated values. May include null results for failed retrievals. + protected Task LookupAsync(TLookup[] ids, CancellationToken token = default) + { + var lookupTasks = new List>(); + + foreach (var id in ids) + { + lookupTasks.Add(LookupAsync(id, token).ContinueWith(task => + { + if (!task.IsCompletedSuccessfully) + return null; + + return task.GetResultSafely(); + }, token)); + } + + return Task.WhenAll(lookupTasks); + } + + // cannot be sealed due to test usages (see TestUserLookupCache). + protected override async Task ComputeValueAsync(TLookup lookup, CancellationToken token = default) + => await queryValue(lookup).ConfigureAwait(false); + + private readonly Queue<(TLookup id, TaskCompletionSource)> pendingTasks = new Queue<(TLookup, TaskCompletionSource)>(); + private Task pendingRequestTask; + private readonly object taskAssignmentLock = new object(); + + private Task queryValue(TLookup id) + { + lock (taskAssignmentLock) + { + var tcs = new TaskCompletionSource(); + + // Add to the queue. + pendingTasks.Enqueue((id, tcs)); + + // Create a request task if there's not already one. + if (pendingRequestTask == null) + createNewTask(); + + return tcs.Task; + } + } + + private void performLookup() + { + // contains at most 50 unique IDs from tasks, which is used to perform the lookup. + var nextTaskBatch = new Dictionary>>(); + + // Grab at most 50 unique IDs from the queue. + lock (taskAssignmentLock) + { + while (pendingTasks.Count > 0 && nextTaskBatch.Count < 50) + { + (TLookup id, TaskCompletionSource task) next = pendingTasks.Dequeue(); + + // Perform a secondary check for existence, in case the value was queried in a previous batch. + if (CheckExists(next.id, out var existing)) + next.task.SetResult(existing); + else + { + if (nextTaskBatch.TryGetValue(next.id, out var tasks)) + tasks.Add(next.task); + else + nextTaskBatch[next.id] = new List> { next.task }; + } + } + } + + if (nextTaskBatch.Count == 0) + { + finishPendingTask(); + return; + } + + // Query the values. + var request = CreateRequest(nextTaskBatch.Keys.ToArray()); + + // rather than queueing, we maintain our own single-threaded request stream. + // todo: we probably want retry logic here. + api.Perform(request); + + finishPendingTask(); + + var foundValues = RetrieveResults(request); + + if (foundValues != null) + { + foreach (var value in foundValues) + { + if (nextTaskBatch.TryGetValue(value.OnlineID, out var tasks)) + { + foreach (var task in tasks) + task.SetResult(value); + + nextTaskBatch.Remove(value.OnlineID); + } + } + } + + // if any tasks remain which were not satisfied, return null. + foreach (var tasks in nextTaskBatch.Values) + { + foreach (var task in tasks) + task.SetResult(null); + } + } + + private void finishPendingTask() + { + // Create a new request task if there's still more values to query. + lock (taskAssignmentLock) + { + pendingRequestTask = null; + if (pendingTasks.Count > 0) + createNewTask(); + } + } + + private void createNewTask() => pendingRequestTask = Task.Run(performLookup); + } +} diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index d8d2cb8981..7cd9ae2885 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -25,7 +25,7 @@ namespace osu.Game.Database public DbSet BeatmapSetInfo { get; set; } public DbSet FileInfo { get; set; } public DbSet RulesetInfo { get; set; } - public DbSet SkinInfo { get; set; } + public DbSet SkinInfo { get; set; } public DbSet ScoreInfo { get; set; } // migrated to realm @@ -133,8 +133,9 @@ namespace osu.Game.Database 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().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 }); @@ -146,7 +147,7 @@ namespace osu.Game.Database modelBuilder.Entity().HasOne(b => b.BaseDifficulty); - modelBuilder.Entity().HasIndex(b => b.OnlineScoreID).IsUnique(); + modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); } private class OsuDbLoggerFactory : ILoggerFactory diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index a20139e830..96c24837a1 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -14,6 +14,7 @@ using osu.Framework.Statistics; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Models; +using osu.Game.Skinning; using osu.Game.Stores; using Realms; @@ -101,10 +102,6 @@ namespace osu.Game.Database // This method triggers the first `CreateContext` call, which will implicitly run realm migrations and bring the schema up-to-date. cleanupPendingDeletions(); - - // Data migration is handled separately from schema migrations. - // This is required as the user may be initialising realm for the first time ever, which would result in no schema migrations running. - migrateDataFromEF(); } private void cleanupPendingDeletions() @@ -122,6 +119,11 @@ namespace osu.Game.Database realm.Remove(s); } + var pendingDeleteSkins = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeleteSkins) + realm.Remove(s); + transaction.Commit(); } @@ -193,53 +195,6 @@ namespace osu.Game.Database }; } - private void migrateDataFromEF() - { - if (efContextFactory == null) - return; - - using (var db = efContextFactory.GetForWrite()) - { - // migrate ruleset settings. can be removed 20220315. - var existingSettings = db.Context.DatabasedSetting; - - // previous entries in EF are removed post migration. - if (!existingSettings.Any()) - return; - - using (var realm = CreateContext()) - using (var transaction = realm.BeginWrite()) - { - // only migrate data if the realm database is empty. - if (!realm.All().Any()) - { - foreach (var dkb in existingSettings) - { - if (dkb.RulesetID == null) - continue; - - string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value); - - if (string.IsNullOrEmpty(shortName)) - continue; - - realm.Add(new RealmRulesetSetting - { - Key = dkb.Key, - Value = dkb.StringValue, - RulesetName = shortName, - Variant = dkb.Variant ?? 0, - }); - } - } - - db.Context.RemoveRange(existingSettings); - - transaction.Commit(); - } - } - } - private void onMigration(Migration migration, ulong lastSchemaVersion) { for (ulong i = lastSchemaVersion + 1; i <= schema_version; i++) diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs index 2accea305a..90b8814c24 100644 --- a/osu.Game/Database/RealmLive.cs +++ b/osu.Game/Database/RealmLive.cs @@ -24,13 +24,17 @@ namespace osu.Game.Database /// private readonly T data; + private readonly RealmContextFactory realmFactory; + /// /// Construct a new instance of live realm data. /// /// The realm data. - public RealmLive(T data) + /// The realm factory the data was sourced from. May be null for an unmanaged object. + public RealmLive(T data, RealmContextFactory realmFactory) { this.data = data; + this.realmFactory = realmFactory; ID = data.ID; } @@ -47,7 +51,7 @@ namespace osu.Game.Database return; } - using (var realm = Realm.GetInstance(data.Realm.Config)) + using (var realm = realmFactory.CreateContext()) perform(realm.Find(ID)); } @@ -58,12 +62,12 @@ namespace osu.Game.Database public TReturn PerformRead(Func perform) { if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn))) - throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}."); + throw new InvalidOperationException(@$"Realm live objects should not exit the scope of {nameof(PerformRead)}."); if (!IsManaged) return perform(data); - using (var realm = Realm.GetInstance(data.Realm.Config)) + using (var realm = realmFactory.CreateContext()) return perform(realm.Find(ID)); } @@ -74,7 +78,7 @@ namespace osu.Game.Database public void PerformWrite(Action perform) { if (!IsManaged) - throw new InvalidOperationException("Can't perform writes on a non-managed underlying value"); + throw new InvalidOperationException(@"Can't perform writes on a non-managed underlying value"); PerformRead(t => { @@ -94,14 +98,12 @@ namespace osu.Game.Database if (!ThreadSafety.IsUpdateThread) throw new InvalidOperationException($"Can't use {nameof(Value)} on managed objects from non-update threads"); - // When using Value, we rely on garbage collection for the realm instance used to retrieve the instance. - // As we are sure that this is on the update thread, there should always be an open and constantly refreshing realm instance to ensure file size growth is a non-issue. - var realm = Realm.GetInstance(data.Realm.Config); - - return realm.Find(ID); + return realmFactory.Context.Find(ID); } } public bool Equals(ILive? other) => ID == other?.ID; + + public override string ToString() => PerformRead(i => i.ToString()); } } diff --git a/osu.Game/Database/RealmLiveUnmanaged.cs b/osu.Game/Database/RealmLiveUnmanaged.cs new file mode 100644 index 0000000000..ea50ccc1ff --- /dev/null +++ b/osu.Game/Database/RealmLiveUnmanaged.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 System; +using Realms; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Provides a method of working with unmanaged realm objects. + /// Usually used for testing purposes where the instance is never required to be managed. + /// + /// The underlying object type. + public class RealmLiveUnmanaged : ILive where T : RealmObjectBase, IHasGuidPrimaryKey + { + /// + /// Construct a new instance of live realm data. + /// + /// The realm data. + public RealmLiveUnmanaged(T data) + { + Value = data; + } + + public bool Equals(ILive? other) => ID == other?.ID; + + public override string ToString() => Value.ToString(); + + public Guid ID => Value.ID; + + public void PerformRead(Action perform) => perform(Value); + + public TReturn PerformRead(Func perform) => perform(Value); + + public void PerformWrite(Action perform) => throw new InvalidOperationException(@"Can't perform writes on a non-managed underlying value"); + + public bool IsManaged => false; + + /// + /// The original live data used to create this instance. + /// + public T Value { get; } + } +} diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index b38e21453c..e09f046421 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -26,6 +26,9 @@ namespace osu.Game.Database /// /// Create a detached copy of the each item in the collection. /// + /// + /// Items which are already detached (ie. not managed by realm) will not be modified. + /// /// A list of managed s to detach. /// The type of object. /// A list containing non-managed copies of provided items. @@ -42,6 +45,9 @@ namespace osu.Game.Database /// /// Create a detached copy of the item. /// + /// + /// If the item if already detached (ie. not managed by realm) it will not be detached again and the original instance will be returned. This allows this method to be potentially called at multiple levels while only incurring the clone overhead once. + /// /// The managed to detach. /// The type of object. /// A non-managed copy of provided item. Will return the provided item if already detached. @@ -53,16 +59,28 @@ namespace osu.Game.Database return mapper.Map(item); } - public static List> ToLive(this IEnumerable realmList) + public static List> ToLiveUnmanaged(this IEnumerable realmList) where T : RealmObject, IHasGuidPrimaryKey { - return realmList.Select(l => new RealmLive(l)).Cast>().ToList(); + return realmList.Select(l => new RealmLiveUnmanaged(l)).Cast>().ToList(); } - public static ILive ToLive(this T realmObject) + public static ILive ToLiveUnmanaged(this T realmObject) where T : RealmObject, IHasGuidPrimaryKey { - return new RealmLive(realmObject); + return new RealmLiveUnmanaged(realmObject); + } + + public static List> ToLive(this IEnumerable realmList, RealmContextFactory realmContextFactory) + where T : RealmObject, IHasGuidPrimaryKey + { + return realmList.Select(l => new RealmLive(l, realmContextFactory)).Cast>().ToList(); + } + + public static ILive ToLive(this T realmObject, RealmContextFactory realmContextFactory) + where T : RealmObject, IHasGuidPrimaryKey + { + return new RealmLive(realmObject, realmContextFactory); } /// diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index 26f4e9fb3b..5fdd80892d 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -6,18 +6,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Database { - public class UserLookupCache : MemoryCachingComponent + public class UserLookupCache : OnlineLookupCache { - [Resolved] - private IAPIProvider api { get; set; } - /// /// Perform an API lookup on the specified user, populating a model. /// @@ -25,7 +20,7 @@ namespace osu.Game.Database /// An optional cancellation token. /// The populated user, or null if the user does not exist or the request could not be satisfied. [ItemCanBeNull] - public Task GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token); + public Task GetUserAsync(int userId, CancellationToken token = default) => LookupAsync(userId, token); /// /// Perform an API lookup on the specified users, populating a model. @@ -33,115 +28,10 @@ namespace osu.Game.Database /// The users to lookup. /// An optional cancellation token. /// The populated users. May include null results for failed retrievals. - public Task GetUsersAsync(int[] userIds, CancellationToken token = default) - { - var userLookupTasks = new List>(); + public Task GetUsersAsync(int[] userIds, CancellationToken token = default) => LookupAsync(userIds, token); - foreach (int u in userIds) - { - userLookupTasks.Add(GetUserAsync(u, token).ContinueWith(task => - { - if (!task.IsCompletedSuccessfully) - return null; + protected override GetUsersRequest CreateRequest(IEnumerable ids) => new GetUsersRequest(ids.ToArray()); - return task.Result; - }, token)); - } - - return Task.WhenAll(userLookupTasks); - } - - protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) - => await queryUser(lookup).ConfigureAwait(false); - - private readonly Queue<(int id, TaskCompletionSource)> pendingUserTasks = new Queue<(int, TaskCompletionSource)>(); - private Task pendingRequestTask; - private readonly object taskAssignmentLock = new object(); - - private Task queryUser(int userId) - { - lock (taskAssignmentLock) - { - var tcs = new TaskCompletionSource(); - - // Add to the queue. - pendingUserTasks.Enqueue((userId, tcs)); - - // Create a request task if there's not already one. - if (pendingRequestTask == null) - createNewTask(); - - return tcs.Task; - } - } - - private void performLookup() - { - // contains at most 50 unique user IDs from userTasks, which is used to perform the lookup. - var userTasks = new Dictionary>>(); - - // Grab at most 50 unique user IDs from the queue. - lock (taskAssignmentLock) - { - while (pendingUserTasks.Count > 0 && userTasks.Count < 50) - { - (int id, TaskCompletionSource task) next = pendingUserTasks.Dequeue(); - - // Perform a secondary check for existence, in case the user was queried in a previous batch. - if (CheckExists(next.id, out var existing)) - next.task.SetResult(existing); - else - { - if (userTasks.TryGetValue(next.id, out var tasks)) - tasks.Add(next.task); - else - userTasks[next.id] = new List> { next.task }; - } - } - } - - if (userTasks.Count == 0) - return; - - // Query the users. - var request = new GetUsersRequest(userTasks.Keys.ToArray()); - - // rather than queueing, we maintain our own single-threaded request stream. - // todo: we probably want retry logic here. - api.Perform(request); - - // Create a new request task if there's still more users to query. - lock (taskAssignmentLock) - { - pendingRequestTask = null; - if (pendingUserTasks.Count > 0) - createNewTask(); - } - - List foundUsers = request.Response?.Users; - - if (foundUsers != null) - { - foreach (var user in foundUsers) - { - if (userTasks.TryGetValue(user.Id, out var tasks)) - { - foreach (var task in tasks) - task.SetResult(user); - - userTasks.Remove(user.Id); - } - } - } - - // if any tasks remain which were not satisfied, return null. - foreach (var tasks in userTasks.Values) - { - foreach (var task in tasks) - task.SetResult(null); - } - } - - private void createNewTask() => pendingRequestTask = Task.Run(performLookup); + protected override IEnumerable RetrieveResults(GetUsersRequest request) => request.Response?.Users; } } diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index 2274da0fd4..f178a5c97b 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -104,6 +104,14 @@ namespace osu.Game.Extensions /// Whether online IDs match. If either instance is missing an online ID, this will return false. public static bool MatchesOnlineID(this APIUser? instance, APIUser? other) => matchesOnlineID(instance, other); + /// + /// Check whether the online ID of two s match. + /// + /// The instance to compare. + /// The other instance to compare against. + /// Whether online IDs match. If either instance is missing an online ID, this will return false. + public static bool MatchesOnlineID(this IScoreInfo? instance, IScoreInfo? other) => matchesOnlineID(instance, other); + private static bool matchesOnlineID(this IHasOnlineID? instance, IHasOnlineID? other) { if (instance == null || other == null) diff --git a/osu.Game/Extensions/WebRequestExtensions.cs b/osu.Game/Extensions/WebRequestExtensions.cs index b940c7498b..50837a648d 100644 --- a/osu.Game/Extensions/WebRequestExtensions.cs +++ b/osu.Game/Extensions/WebRequestExtensions.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.Globalization; +using Newtonsoft.Json.Linq; using osu.Framework.IO.Network; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Online.API.Requests; @@ -16,7 +18,7 @@ namespace osu.Game.Extensions { cursor?.Properties.ForEach(x => { - webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString()); + webRequest.AddParameter("cursor[" + x.Key + "]", (x.Value as JValue)?.ToString(CultureInfo.InvariantCulture) ?? x.Value.ToString()); }); } } diff --git a/osu.Game/Graphics/Backgrounds/Background.cs b/osu.Game/Graphics/Backgrounds/Background.cs index 353054a1f1..b09ec1d9b9 100644 --- a/osu.Game/Graphics/Backgrounds/Background.cs +++ b/osu.Game/Graphics/Backgrounds/Background.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,8 +18,6 @@ namespace osu.Game.Graphics.Backgrounds /// public class Background : CompositeDrawable, IEquatable { - private const float blur_scale = 0.5f; - public readonly Sprite Sprite; private readonly string textureName; @@ -46,7 +45,7 @@ namespace osu.Game.Graphics.Backgrounds Sprite.Texture = textures.Get(textureName); } - public Vector2 BlurSigma => bufferedContainer?.BlurSigma / blur_scale ?? Vector2.Zero; + public Vector2 BlurSigma => Vector2.Divide(bufferedContainer?.BlurSigma ?? Vector2.Zero, blurScale); /// /// Smoothly adjusts over time. @@ -67,9 +66,48 @@ namespace osu.Game.Graphics.Backgrounds } if (bufferedContainer != null) - bufferedContainer.FrameBufferScale = newBlurSigma == Vector2.Zero ? Vector2.One : new Vector2(blur_scale); + transformBlurSigma(newBlurSigma, duration, easing); + } - bufferedContainer?.BlurTo(newBlurSigma * blur_scale, duration, easing); + private void transformBlurSigma(Vector2 newBlurSigma, double duration, Easing easing) + => this.TransformTo(nameof(blurSigma), newBlurSigma, duration, easing); + + private Vector2 blurSigmaBacking = Vector2.Zero; + private Vector2 blurScale = Vector2.One; + + private Vector2 blurSigma + { + get => blurSigmaBacking; + set + { + Debug.Assert(bufferedContainer != null); + + blurSigmaBacking = value; + blurScale = new Vector2(calculateBlurDownscale(value.X), calculateBlurDownscale(value.Y)); + + bufferedContainer.FrameBufferScale = blurScale; + bufferedContainer.BlurSigma = value * blurScale; // If the image is scaled down, the blur radius also needs to be reduced to cover the same pixel block. + } + } + + /// + /// Determines a factor to downscale the background based on a given blur sigma, in order to reduce the computational complexity of blurs. + /// + /// The blur sigma. + /// The scale-down factor. + private float calculateBlurDownscale(float sigma) + { + // If we're blurring within one pixel, scaling down will always result in an undesirable loss of quality. + // The algorithm below would also cause this value to go above 1, which is likewise undesirable. + if (sigma <= 1) + return 1; + + // A good value is one where the loss in quality as a result of downscaling the image is not easily perceivable. + // The constants here have been experimentally chosen to yield nice transitions by approximating a log curve through the points {{ 1, 1 }, { 4, 0.75 }, { 16, 0.5 }, { 32, 0.25 }}. + float scale = -0.18f * MathF.Log(0.004f * sigma); + + // To reduce shimmering, the scaling transitions are limited to happen only in increments of 0.2. + return MathF.Round(scale / 0.2f, MidpointRounding.AwayFromZero) * 0.2f; } public virtual bool Equals(Background other) diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs index 6a42e83305..56ef87c1f4 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs @@ -1,20 +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 enable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Storyboards.Drawables; namespace osu.Game.Graphics.Backgrounds { public class BeatmapBackgroundWithStoryboard : BeatmapBackground { + private readonly InterpolatingFramedClock storyboardClock; + + [Resolved(CanBeNull = true)] + private MusicController? musicController { get; set; } + public BeatmapBackgroundWithStoryboard(WorkingBeatmap beatmap, string fallbackTextureName = "Backgrounds/bg1") : base(beatmap, fallbackTextureName) { + storyboardClock = new InterpolatingFramedClock(); } [BackgroundDependencyLoader] @@ -30,8 +39,40 @@ namespace osu.Game.Graphics.Backgrounds { RelativeSizeAxes = Axes.Both, Volume = { Value = 0 }, - Child = new DrawableStoryboard(Beatmap.Storyboard) { Clock = new InterpolatingFramedClock(Beatmap.Track) } + Child = new DrawableStoryboard(Beatmap.Storyboard) { Clock = storyboardClock } }, AddInternal); } + + protected override void LoadComplete() + { + base.LoadComplete(); + if (musicController != null) + musicController.TrackChanged += onTrackChanged; + + updateStoryboardClockSource(Beatmap); + } + + private void onTrackChanged(WorkingBeatmap newBeatmap, TrackChangeDirection _) => updateStoryboardClockSource(newBeatmap); + + private void updateStoryboardClockSource(WorkingBeatmap newBeatmap) + { + if (newBeatmap != Beatmap) + return; + + // `MusicController` will sometimes reload the track, even when the working beatmap technically hasn't changed. + // ensure that the storyboard's clock is always using the latest track instance. + storyboardClock.ChangeSource(newBeatmap.Track); + // more often than not, the previous source track's time will be in the future relative to the new source track. + // explicitly process a single frame so that `InterpolatingFramedClock`'s interpolation logic is bypassed + // and the storyboard clock is correctly rewound to the source track's time exactly. + storyboardClock.ProcessFrame(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + if (musicController != null) + musicController.TrackChanged -= onTrackChanged; + } } } diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 6e4901ab1a..2024d18570 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -111,7 +111,7 @@ namespace osu.Game.Graphics.Containers if (clock == null) return; - double currentTrackTime = clock.CurrentTime; + double currentTrackTime = clock.CurrentTime + EarlyActivationMilliseconds; if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) { @@ -132,13 +132,11 @@ namespace osu.Game.Graphics.Containers { // this may be the case where the beat syncing clock has been paused. // we still want to show an idle animation, so use this container's time instead. - currentTrackTime = Clock.CurrentTime; + currentTrackTime = Clock.CurrentTime + EarlyActivationMilliseconds; timingPoint = TimingControlPoint.DEFAULT; effectPoint = EffectControlPoint.DEFAULT; } - currentTrackTime += EarlyActivationMilliseconds; - double beatLength = timingPoint.BeatLength / Divisor; while (beatLength < MinimumBeatLength) diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs index 3fa90e2330..8e272f637f 100644 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ b/osu.Game/Graphics/Cursor/MenuCursor.cs @@ -72,18 +72,21 @@ namespace osu.Game.Graphics.Cursor protected override bool OnMouseDown(MouseDownEvent e) { - // 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 (State.Value == Visibility.Visible) { - // if cursor is already rotating don't reset its rotate origin - dragRotationState = DragRotationState.DragStarted; - positionMouseDown = e.MousePosition; + // 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; + } } return base.OnMouseDown(e); diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs index 3094f9cc2b..2dca8719e9 100644 --- a/osu.Game/Graphics/DateTooltip.cs +++ b/osu.Game/Graphics/DateTooltip.cs @@ -56,7 +56,7 @@ namespace osu.Game.Graphics [BackgroundDependencyLoader] private void load(OsuColour colours) { - background.Colour = colours.GreySeafoamDarker; + background.Colour = colours.GreySeaFoamDarker; timeText.Colour = colours.BlueLighter; } @@ -65,8 +65,10 @@ namespace osu.Game.Graphics public void SetContent(DateTimeOffset date) { - dateText.Text = $"{date:d MMMM yyyy} "; - timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; + DateTimeOffset localDate = date.ToLocalTime(); + + dateText.Text = $"{localDate:d MMMM yyyy} "; + timeText.Text = $"{localDate:HH:mm:ss \"UTC\"z}"; } public void Move(Vector2 pos) => Position = pos; diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 69dfd3a7a1..f63bd53549 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -212,12 +212,12 @@ namespace osu.Game.Graphics public readonly Color4 GreySkyDark = Color4Extensions.FromHex(@"303d47"); public readonly Color4 GreySkyDarker = Color4Extensions.FromHex(@"21272c"); - public readonly Color4 Seafoam = Color4Extensions.FromHex(@"05ffa2"); - public readonly Color4 GreySeafoamLighter = Color4Extensions.FromHex(@"9ebab1"); - public readonly Color4 GreySeafoamLight = Color4Extensions.FromHex(@"4d7365"); - public readonly Color4 GreySeafoam = Color4Extensions.FromHex(@"33413c"); - public readonly Color4 GreySeafoamDark = Color4Extensions.FromHex(@"2c3532"); - public readonly Color4 GreySeafoamDarker = Color4Extensions.FromHex(@"1e2422"); + public readonly Color4 SeaFoam = Color4Extensions.FromHex(@"05ffa2"); + public readonly Color4 GreySeaFoamLighter = Color4Extensions.FromHex(@"9ebab1"); + public readonly Color4 GreySeaFoamLight = Color4Extensions.FromHex(@"4d7365"); + public readonly Color4 GreySeaFoam = Color4Extensions.FromHex(@"33413c"); + public readonly Color4 GreySeaFoamDark = Color4Extensions.FromHex(@"2c3532"); + public readonly Color4 GreySeaFoamDarker = Color4Extensions.FromHex(@"1e2422"); public readonly Color4 Cyan = Color4Extensions.FromHex(@"05f4fd"); public readonly Color4 GreyCyanLighter = Color4Extensions.FromHex(@"77b1b3"); diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index 982e9dacab..e8267edab0 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -39,10 +39,10 @@ namespace osu.Game.Graphics public static IconUsage ListSearch => Get(0xe032); //osu! playstyles - public static IconUsage PlaystyleTablet => Get(0xe02a); - public static IconUsage PlaystyleMouse => Get(0xe029); - public static IconUsage PlaystyleKeyboard => Get(0xe02b); - public static IconUsage PlaystyleTouch => Get(0xe02c); + public static IconUsage PlayStyleTablet => Get(0xe02a); + public static IconUsage PlayStyleMouse => Get(0xe029); + public static IconUsage PlayStyleKeyboard => Get(0xe02b); + public static IconUsage PlayStyleTouch => Get(0xe02c); // osu! difficulties public static IconUsage EasyOsu => Get(0xe015); @@ -77,17 +77,17 @@ namespace osu.Game.Graphics public static IconUsage ModAutopilot => Get(0xe03a); public static IconUsage ModAuto => Get(0xe03b); public static IconUsage ModCinema => Get(0xe03c); - public static IconUsage ModDoubletime => Get(0xe03d); + public static IconUsage ModDoubleTime => Get(0xe03d); public static IconUsage ModEasy => Get(0xe03e); public static IconUsage ModFlashlight => Get(0xe03f); public static IconUsage ModHalftime => Get(0xe040); - public static IconUsage ModHardrock => Get(0xe041); + public static IconUsage ModHardRock => Get(0xe041); public static IconUsage ModHidden => Get(0xe042); public static IconUsage ModNightcore => Get(0xe043); - public static IconUsage ModNofail => Get(0xe044); + public static IconUsage ModNoFail => Get(0xe044); public static IconUsage ModRelax => Get(0xe045); - public static IconUsage ModSpunout => Get(0xe046); - public static IconUsage ModSuddendeath => Get(0xe047); + public static IconUsage ModSpunOut => Get(0xe046); + public static IconUsage ModSuddenDeath => Get(0xe047); public static IconUsage ModTarget => Get(0xe048); public static IconUsage ModBg => Get(0xe04a); } diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs index b1383065fe..36fcd39b54 100644 --- a/osu.Game/Graphics/Sprites/LogoAnimation.cs +++ b/osu.Game/Graphics/Sprites/LogoAnimation.cs @@ -7,14 +7,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; namespace osu.Game.Graphics.Sprites { public class LogoAnimation : Sprite { [BackgroundDependencyLoader] - private void load(ShaderManager shaders, TextureStore textures) + private void load(ShaderManager shaders) { TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); // Masking isn't supported for now diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index fea84998cf..4267b82bb7 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -30,7 +29,7 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load() { BackgroundColour = Color4.Transparent; BackgroundColourHover = Color4Extensions.FromHex(@"172023"); diff --git a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs index 1fd03a34e7..34ab7626c9 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -18,7 +17,7 @@ namespace osu.Game.Graphics.UserInterface private Bindable lastPlaybackTime; [BackgroundDependencyLoader] - private void load(AudioManager audio, SessionStatics statics) + private void load(SessionStatics statics) { lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); } diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 70a107ca04..13b42c0f13 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -38,6 +38,9 @@ namespace osu.Game.Graphics.UserInterface } } + [Resolved] + private OsuColour colours { get; set; } + protected override Container Content => content; private readonly Container content; @@ -73,17 +76,25 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { if (AutoSizeAxes != Axes.None) { content.RelativeSizeAxes = (Axes.Both & ~AutoSizeAxes); content.AutoSizeAxes = AutoSizeAxes; } - - Enabled.BindValueChanged(enabled => this.FadeColour(enabled.NewValue ? Color4.White : colours.Gray9, 200, Easing.OutQuint), true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + Colour = dimColour; + Enabled.BindValueChanged(_ => this.FadeColour(dimColour, 200, Easing.OutQuint)); + } + + private Color4 dimColour => Enabled.Value ? Color4.White : colours.Gray9; + protected override bool OnHover(HoverEvent e) { hover.FadeIn(500, Easing.OutQuint); diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 82a3e73b84..7c1e8d90a0 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -85,8 +84,6 @@ namespace osu.Game.Graphics.UserInterface if (hoverSounds.HasValue) AddInternal(new HoverClickSounds(hoverSounds.Value)); - - Enabled.BindValueChanged(enabledChanged, true); } [BackgroundDependencyLoader] @@ -94,11 +91,18 @@ namespace osu.Game.Graphics.UserInterface { if (backgroundColour == null) BackgroundColour = colours.BlueDark; - - Enabled.ValueChanged += enabledChanged; - Enabled.TriggerChange(); } + protected override void LoadComplete() + { + base.LoadComplete(); + + Colour = dimColour; + Enabled.BindValueChanged(_ => this.FadeColour(dimColour, 200, Easing.OutQuint)); + } + + private Color4 dimColour => Enabled.Value ? Color4.White : Color4.Gray; + protected override bool OnClick(ClickEvent e) { if (Enabled.Value) @@ -144,10 +148,5 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.Centre, Font = OsuFont.GetFont(weight: FontWeight.Bold) }; - - private void enabledChanged(ValueChangedEvent e) - { - this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint); - } } } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index cf201b18b4..e0946fd9e1 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -3,7 +3,6 @@ using osuTK.Graphics; using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; @@ -41,7 +40,7 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours, AudioManager audio) + private void load(OsuColour colours) { BackgroundColour = colours.ContextMenuGray; } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs index d67ea499e5..921fef7951 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs @@ -16,7 +16,7 @@ namespace osu.Game.Graphics.UserInterface private Sample sampleClose; [BackgroundDependencyLoader] - private void load(OsuColour colours, AudioManager audio) + private void load(AudioManager audio) { sampleClick = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index 36288c745a..3d565a4464 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,10 +1,12 @@ // 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; + namespace osu.Game.Graphics.UserInterface { public class OsuNumberBox : OsuTextBox { - protected override bool CanAddCharacter(char character) => char.IsNumber(character); + protected override bool CanAddCharacter(char character) => character.IsAsciiDigit(); } } diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 96319b9fdd..6db3068d84 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -93,7 +93,7 @@ namespace osu.Game.Graphics.UserInterface if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) capsTextAddedSample?.Play(); else - textAddedSamples[RNG.Next(0, 3)]?.Play(); + playTextAddedSample(); } protected override void OnUserTextRemoved(string removed) @@ -117,6 +117,70 @@ namespace osu.Game.Graphics.UserInterface caretMovedSample?.Play(); } + protected override void OnImeComposition(string newComposition, int removedTextLength, int addedTextLength, bool caretMoved) + { + base.OnImeComposition(newComposition, removedTextLength, addedTextLength, caretMoved); + + if (string.IsNullOrEmpty(newComposition)) + { + switch (removedTextLength) + { + case 0: + // empty composition event, composition wasn't changed, don't play anything. + return; + + case 1: + // composition probably ended by pressing backspace, or was cancelled. + textRemovedSample?.Play(); + return; + + default: + // longer text removed, composition ended because it was cancelled. + // could be a different sample if desired. + textRemovedSample?.Play(); + return; + } + } + + if (addedTextLength > 0) + { + // some text was added, probably due to typing new text or by changing the candidate. + playTextAddedSample(); + return; + } + + if (removedTextLength > 0) + { + // text was probably removed by backspacing. + // it's also possible that a candidate that only removed text was changed to. + textRemovedSample?.Play(); + return; + } + + if (caretMoved) + { + // only the caret/selection was moved. + caretMovedSample?.Play(); + } + } + + protected override void OnImeResult(string result, bool successful) + { + base.OnImeResult(result, successful); + + if (successful) + { + // composition was successfully completed, usually by pressing the enter key. + textCommittedSample?.Play(); + } + else + { + // composition was prematurely ended, eg. by clicking inside the textbox. + // could be a different sample if desired. + textCommittedSample?.Play(); + } + } + protected override void OnFocus(FocusEvent e) { BorderThickness = 3; @@ -142,6 +206,8 @@ namespace osu.Game.Graphics.UserInterface SelectionColour = SelectionColour, }; + private void playTextAddedSample() => textAddedSamples[RNG.Next(0, textAddedSamples.Length)]?.Play(); + private class OsuCaret : Caret { private const float caret_move_time = 60; diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.cs new file mode 100644 index 0000000000..d73d9f5824 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterface.PageSelector +{ + internal class PageEllipsis : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; + + InternalChildren = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = "...", + Colour = colourProvider.Light3, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.cs new file mode 100644 index 0000000000..005729580c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.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; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Bindables; + +namespace osu.Game.Graphics.UserInterface.PageSelector +{ + public class PageSelector : CompositeDrawable + { + public readonly BindableInt CurrentPage = new BindableInt { MinValue = 0, }; + + public readonly BindableInt AvailablePages = new BindableInt(1) { MinValue = 1, }; + + private readonly FillFlowContainer itemsFlow; + + private readonly PageSelectorPrevNextButton previousPageButton; + private readonly PageSelectorPrevNextButton nextPageButton; + + public PageSelector() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + previousPageButton = new PageSelectorPrevNextButton(false, "prev") + { + Action = () => CurrentPage.Value -= 1, + }, + itemsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }, + nextPageButton = new PageSelectorPrevNextButton(true, "next") + { + Action = () => CurrentPage.Value += 1 + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + CurrentPage.BindValueChanged(_ => Scheduler.AddOnce(redraw)); + AvailablePages.BindValueChanged(_ => + { + CurrentPage.Value = 0; + + // AddOnce as the reset of CurrentPage may also trigger a redraw. + Scheduler.AddOnce(redraw); + }, true); + } + + private void redraw() + { + if (CurrentPage.Value >= AvailablePages.Value) + { + CurrentPage.Value = AvailablePages.Value - 1; + return; + } + + previousPageButton.Enabled.Value = CurrentPage.Value != 0; + nextPageButton.Enabled.Value = CurrentPage.Value < AvailablePages.Value - 1; + + itemsFlow.Clear(); + + int totalPages = AvailablePages.Value; + bool lastWasEllipsis = false; + + for (int i = 0; i < totalPages; i++) + { + int pageIndex = i; + + bool shouldShowPage = pageIndex == 0 || pageIndex == totalPages - 1 || Math.Abs(pageIndex - CurrentPage.Value) <= 2; + + if (shouldShowPage) + { + lastWasEllipsis = false; + itemsFlow.Add(new PageSelectorPageButton(pageIndex + 1) + { + Action = () => CurrentPage.Value = pageIndex, + Selected = CurrentPage.Value == pageIndex, + }); + } + else if (!lastWasEllipsis) + { + lastWasEllipsis = true; + itemsFlow.Add(new PageEllipsis()); + } + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.cs new file mode 100644 index 0000000000..a2c6e8532b --- /dev/null +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.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.Containers; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osu.Framework.Input.Events; +using JetBrains.Annotations; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterface.PageSelector +{ + public abstract class PageSelectorButton : OsuClickableContainer + { + protected const int DURATION = 200; + + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } + + protected Box Background; + + protected PageSelectorButton() + { + AutoSizeAxes = Axes.X; + Height = 20; + } + + [BackgroundDependencyLoader] + private void load() + { + Add(new CircularContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + Children = new[] + { + Background = new Box + { + RelativeSizeAxes = Axes.Both + }, + CreateContent().With(content => + { + content.Anchor = Anchor.Centre; + content.Origin = Anchor.Centre; + content.Margin = new MarginPadding { Horizontal = 10 }; + }) + } + }); + } + + [NotNull] + protected abstract Drawable CreateContent(); + + protected override bool OnHover(HoverEvent e) + { + UpdateHoverState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + UpdateHoverState(); + } + + protected abstract void UpdateHoverState(); + } +} diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs new file mode 100644 index 0000000000..247a003492 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs @@ -0,0 +1,70 @@ +// 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.Bindables; +using osu.Framework.Allocation; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface.PageSelector +{ + public class PageSelectorPageButton : PageSelectorButton + { + private readonly BindableBool selected = new BindableBool(); + + public bool Selected + { + set => selected.Value = value; + } + + public int PageNumber { get; } + + private OsuSpriteText text; + + public PageSelectorPageButton(int pageNumber) + { + PageNumber = pageNumber; + + Action = () => + { + if (!selected.Value) + selected.Value = true; + }; + } + + protected override Drawable CreateContent() => text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = PageNumber.ToString(), + }; + + [BackgroundDependencyLoader] + private void load() + { + Background.Colour = ColourProvider.Highlight1; + Background.Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + selected.BindValueChanged(onSelectedChanged, true); + } + + private void onSelectedChanged(ValueChangedEvent selected) + { + Background.FadeTo(selected.NewValue ? 1 : 0, DURATION, Easing.OutQuint); + + text.FadeColour(selected.NewValue ? ColourProvider.Dark4 : ColourProvider.Light3, DURATION, Easing.OutQuint); + text.Font = text.Font.With(weight: IsHovered ? FontWeight.SemiBold : FontWeight.Regular); + } + + protected override void UpdateHoverState() + { + if (selected.Value) + return; + + text.FadeColour(IsHovered ? ColourProvider.Light2 : ColourProvider.Light1, DURATION, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs new file mode 100644 index 0000000000..7503ab8135 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs @@ -0,0 +1,78 @@ +// 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.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterface.PageSelector +{ + public class PageSelectorPrevNextButton : PageSelectorButton + { + private readonly bool rightAligned; + private readonly string text; + + private SpriteIcon icon; + private OsuSpriteText name; + + public PageSelectorPrevNextButton(bool rightAligned, string text) + { + this.rightAligned = rightAligned; + this.text = text; + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(3, 0), + Children = new Drawable[] + { + name = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Anchor = rightAligned ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = rightAligned ? Anchor.CentreLeft : Anchor.CentreRight, + Text = text.ToUpper(), + }, + icon = new SpriteIcon + { + Icon = rightAligned ? FontAwesome.Solid.ChevronRight : FontAwesome.Solid.ChevronLeft, + Size = new Vector2(8), + Anchor = rightAligned ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = rightAligned ? Anchor.CentreLeft : Anchor.CentreRight, + }, + } + }, + } + }; + + [BackgroundDependencyLoader] + private void load() + { + Background.Colour = ColourProvider.Dark4; + name.Colour = icon.Colour = ColourProvider.Light1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(enabled => Background.FadeTo(enabled.NewValue ? 1 : 0.5f, DURATION), true); + } + + protected override void UpdateHoverState() => + Background.FadeColour(IsHovered ? ColourProvider.Dark3 : ColourProvider.Dark4, DURATION, Easing.OutQuint); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs index 618c7dabfa..ce2e7794a9 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs @@ -54,7 +54,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 InternalChild = new Box { - Colour = colours.GreySeafoamDarker, + Colour = colours.GreySeaFoamDarker, RelativeSizeAxes = Axes.Both, }; } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs index 30e38e8938..0189b30aad 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs @@ -26,7 +26,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader(true)] private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour osuColour) { - Background.Colour = colourProvider?.Dark5 ?? osuColour.GreySeafoamDark; + Background.Colour = colourProvider?.Dark5 ?? osuColour.GreySeaFoamDark; Content.Padding = new MarginPadding(spacing); Content.Spacing = new Vector2(0, spacing); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs index 331a1b67c9..5368a800bc 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs @@ -23,7 +23,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader(true)] private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour osuColour) { - Background.Colour = overlayColourProvider?.Dark6 ?? osuColour.GreySeafoamDarker; + Background.Colour = overlayColourProvider?.Dark6 ?? osuColour.GreySeaFoamDarker; } protected override TextBox CreateHexCodeTextBox() => new OsuTextBox(); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs index d1857dd174..085541b3ef 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs @@ -39,7 +39,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader(true)] private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) { - Background.Colour = Arrow.Colour = colourProvider?.Background4 ?? colours.GreySeafoamDarker; + Background.Colour = Arrow.Colour = colourProvider?.Background4 ?? colours.GreySeaFoamDarker; } protected override Drawable CreateArrow() => Empty(); diff --git a/osu.Game/IO/Archives/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs index 679ab40402..1d8da16c72 100644 --- a/osu.Game/IO/Archives/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using osu.Framework.IO.Stores; @@ -31,9 +32,7 @@ namespace osu.Game.IO.Archives public abstract IEnumerable Filenames { get; } - public virtual byte[] Get(string name) => GetAsync(name).Result; - - public async Task GetAsync(string name) + public virtual byte[] Get(string name) { using (Stream input = GetStream(name)) { @@ -41,7 +40,20 @@ namespace osu.Game.IO.Archives return null; byte[] buffer = new byte[input.Length]; - await input.ReadAsync(buffer).ConfigureAwait(false); + input.Read(buffer); + return buffer; + } + } + + public async Task GetAsync(string name, CancellationToken cancellationToken = default) + { + using (Stream input = GetStream(name)) + { + if (input == null) + return null; + + byte[] buffer = new byte[input.Length]; + await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); return buffer; } } diff --git a/osu.Game/IO/FileInfo.cs b/osu.Game/IO/FileInfo.cs index 277ad0adac..148afba40d 100644 --- a/osu.Game/IO/FileInfo.cs +++ b/osu.Game/IO/FileInfo.cs @@ -9,6 +9,8 @@ namespace osu.Game.IO { public int ID { get; set; } + public bool IsManaged => ID > 0; + public string Hash { get; set; } public int ReferenceCount { get; set; } diff --git a/osu.Game/IO/IStorageResourceProvider.cs b/osu.Game/IO/IStorageResourceProvider.cs index e4c97e18fa..950b5aae09 100644 --- a/osu.Game/IO/IStorageResourceProvider.cs +++ b/osu.Game/IO/IStorageResourceProvider.cs @@ -4,6 +4,7 @@ using osu.Framework.Audio; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Game.Database; namespace osu.Game.IO { @@ -24,6 +25,11 @@ namespace osu.Game.IO /// IResourceStore Resources { get; } + /// + /// Access realm. + /// + RealmContextFactory RealmContextFactory { get; } + /// /// Create a texture loader store based on an underlying data store. /// diff --git a/osu.Game/IO/Legacy/SerializationReader.cs b/osu.Game/IO/Legacy/SerializationReader.cs index 00f90f78e3..f7b3f33e87 100644 --- a/osu.Game/IO/Legacy/SerializationReader.cs +++ b/osu.Game/IO/Legacy/SerializationReader.cs @@ -235,9 +235,9 @@ namespace osu.Game.IO.Legacy if (typeName.Contains("System.Collections.Generic") && typeName.Contains("[[")) { - string[] splitTyps = typeName.Split('['); + string[] splitTypes = typeName.Split('['); - foreach (string typ in splitTyps) + foreach (string typ in splitTypes) { if (typ.Contains("Version")) { diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index d9d0e4c0ea..f381aad39a 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -18,6 +18,7 @@ namespace osu.Game.IPC : base(host) { this.importer = importer; + MessageReceived += msg => { Debug.Assert(importer != null); @@ -25,6 +26,8 @@ namespace osu.Game.IPC { if (t.Exception != null) throw t.Exception; }, TaskContinuationOptions.OnlyOnFaulted); + + return null; }; } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index c71cb6a00a..47cb7be2cf 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -77,6 +77,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay), + new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally), + new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), }; public IEnumerable InGameKeyBindings => new[] @@ -292,6 +294,12 @@ namespace osu.Game.Input.Bindings EditorCycleGridDisplayMode, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestGameplay))] - EditorTestGameplay + EditorTestGameplay, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorFlipHorizontally))] + EditorFlipHorizontally, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorFlipVertically))] + EditorFlipVertically, } } diff --git a/osu.Game/Input/IdleTracker.cs b/osu.Game/Input/IdleTracker.cs index bfe21f650a..e2f13309cf 100644 --- a/osu.Game/Input/IdleTracker.cs +++ b/osu.Game/Input/IdleTracker.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Platform; using osu.Game.Input.Bindings; namespace osu.Game.Input @@ -63,8 +65,17 @@ namespace osu.Game.Input public void OnReleased(KeyBindingReleaseEvent e) => updateLastInteractionTime(); + [Resolved] + private GameHost host { get; set; } + protected override bool Handle(UIEvent e) { + // Even when not active, `MouseMoveEvent`s will arrive. + // We don't want these to trigger a non-idle state as it's quite often the user interacting + // with other windows while osu! is in the background. + if (!host.IsActive.Value) + return base.Handle(e); + switch (e) { case KeyDownEvent _: diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs index 3bdb0a180d..cb51797685 100644 --- a/osu.Game/Input/RealmKeyBindingStore.cs +++ b/osu.Game/Input/RealmKeyBindingStore.cs @@ -81,20 +81,37 @@ namespace osu.Game.Input // compare counts in database vs defaults for each action type. foreach (var defaultsForAction in defaults.GroupBy(k => k.Action)) { - // avoid performing redundant queries when the database is empty and needs to be re-filled. - int existingCount = existingBindings.Count(k => k.RulesetName == rulesetName && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key); + IEnumerable existing = existingBindings.Where(k => + k.RulesetName == rulesetName + && k.Variant == variant + && k.ActionInt == (int)defaultsForAction.Key); - if (defaultsForAction.Count() <= existingCount) - continue; + int defaultsCount = defaultsForAction.Count(); + int existingCount = existing.Count(); - // insert any defaults which are missing. - realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding + if (defaultsCount > existingCount) { - KeyCombinationString = k.KeyCombination.ToString(), - ActionInt = (int)k.Action, - RulesetName = rulesetName, - Variant = variant - })); + // insert any defaults which are missing. + realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding + { + KeyCombinationString = k.KeyCombination.ToString(), + ActionInt = (int)k.Action, + RulesetName = rulesetName, + Variant = variant + })); + } + else if (defaultsCount < existingCount) + { + // generally this shouldn't happen, but if the user has more key bindings for an action than we expect, + // remove the last entries until the count matches for sanity. + foreach (var k in existing.TakeLast(existingCount - defaultsCount).ToArray()) + { + realm.Remove(k); + + // Remove from the local flattened/cached list so future lookups don't query now deleted rows. + existingBindings.Remove(k); + } + } } } diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index 008781c2e5..f298717c99 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -32,6 +32,11 @@ namespace osu.Game.Localisation /// /// "Master" /// + public static LocalisableString PositionalLevel => new TranslatableString(getKey(@"positional_hitsound_audio_level"), @"Hitsound stereo separation"); + + /// + /// "Level" + /// public static LocalisableString MasterVolume => new TranslatableString(getKey(@"master_volume"), @"Master"); /// diff --git a/osu.Game/Localisation/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs index dd21739096..74b2c8d892 100644 --- a/osu.Game/Localisation/DebugSettingsStrings.cs +++ b/osu.Game/Localisation/DebugSettingsStrings.cs @@ -44,6 +44,11 @@ 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/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index fa92187650..84c3704e26 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -84,11 +84,6 @@ namespace osu.Game.Localisation /// public static LocalisableString AlwaysShowKeyOverlay => new TranslatableString(getKey(@"key_overlay"), @"Always show key overlay"); - /// - /// "Positional hitsounds" - /// - public static LocalisableString PositionalHitsounds => new TranslatableString(getKey(@"positional_hitsounds"), @"Positional hitsounds"); - /// /// "Always play first combo break sound" /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 35a0c2ae74..777e97d1e3 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -229,6 +229,16 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorNudgeRight => new TranslatableString(getKey(@"editor_nudge_right"), @"Nudge selection right"); + /// + /// "Flip selection horizontally" + /// + public static LocalisableString EditorFlipHorizontally => new TranslatableString(getKey(@"editor_flip_horizontally"), @"Flip selection horizontally"); + + /// + /// "Flip selection vertically" + /// + public static LocalisableString EditorFlipVertically => new TranslatableString(getKey(@"editor_flip_vertically"), @"Flip selection vertically"); + /// /// "Toggle skin editor" /// diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs index 5e894c4e0b..fd7225ad2e 100644 --- a/osu.Game/Localisation/MouseSettingsStrings.cs +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -35,9 +35,14 @@ namespace osu.Game.Localisation public static LocalisableString ConfineMouseMode => new TranslatableString(getKey(@"confine_mouse_mode"), @"Confine mouse cursor to window"); /// - /// "Disable mouse wheel during gameplay" + /// "Disable mouse wheel adjusting volume during gameplay" /// - public static LocalisableString DisableMouseWheel => new TranslatableString(getKey(@"disable_mouse_wheel"), @"Disable mouse wheel during gameplay"); + public static LocalisableString DisableMouseWheelVolumeAdjust => new TranslatableString(getKey(@"disable_mouse_wheel_volume_adjust"), @"Disable mouse wheel adjusting volume during gameplay"); + + /// + /// "Volume can still be adjusted using the mouse wheel by holding "Alt"" + /// + public static LocalisableString DisableMouseWheelVolumeAdjustTooltip => new TranslatableString(getKey(@"disable_mouse_wheel_volume_adjust_tooltip"), @"Volume can still be adjusted using the mouse wheel by holding ""Alt"""); /// /// "Disable mouse buttons during gameplay" diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs index 82dc0ad110..0fb85e4a19 100644 --- a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Resources; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Localisation; @@ -75,7 +76,7 @@ namespace osu.Game.Localisation } } - public Task GetAsync(string lookup) + public Task GetAsync(string lookup, CancellationToken cancellationToken = default) { return Task.FromResult(Get(lookup)); } diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs index 9a7488fda2..b959d0b4dc 100644 --- a/osu.Game/Models/RealmRuleset.cs +++ b/osu.Game/Models/RealmRuleset.cs @@ -50,6 +50,8 @@ namespace osu.Game.Models public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; + public bool Equals(IRulesetInfo? other) => other is RealmRuleset b && Equals(b); + public override string ToString() => Name; public RealmRuleset Clone() => new RealmRuleset diff --git a/osu.Game/Online/API/APIException.cs b/osu.Game/Online/API/APIException.cs index 97786bced9..54d68d8f0d 100644 --- a/osu.Game/Online/API/APIException.cs +++ b/osu.Game/Online/API/APIException.cs @@ -7,8 +7,8 @@ namespace osu.Game.Online.API { public class APIException : InvalidOperationException { - public APIException(string messsage, Exception innerException) - : base(messsage, innerException) + public APIException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index efb0b102d0..91148c177f 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -42,7 +42,7 @@ namespace osu.Game.Online.API if (WebRequest != null) { Response = ((OsuJsonWebRequest)WebRequest).ResponseObject; - Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes"); + Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes", LoggingTarget.Network); } } diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 6cd45a41df..671f543422 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -26,9 +26,12 @@ namespace osu.Game.Online.API.Requests { var request = base.CreateWebRequest(); - request.AddParameter(@"id", beatmapInfo.OnlineID.ToString()); - request.AddParameter(@"checksum", beatmapInfo.MD5Hash); - 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/GetUserRecentActivitiesRequest.cs b/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs index 123624d333..f2fa51bde7 100644 --- a/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs @@ -22,6 +22,8 @@ namespace osu.Game.Online.API.Requests public enum RecentActivityType { Achievement, + + // ReSharper disable once IdentifierTypo BeatmapPlaycount, BeatmapsetApprove, BeatmapsetDelete, diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index e32451fc2f..28da5222f9 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests public class GetUserRequest : APIRequest { public readonly string Lookup; - public readonly RulesetInfo Ruleset; + public readonly IRulesetInfo Ruleset; private readonly LookupType lookupType; /// @@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(long? userId = null, RulesetInfo ruleset = null) + public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null) { Lookup = userId.ToString(); lookupType = LookupType.Id; @@ -36,7 +36,7 @@ namespace osu.Game.Online.API.Requests /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(string username = null, RulesetInfo ruleset = null) + public GetUserRequest(string username = null, IRulesetInfo ruleset = null) { Lookup = username; lookupType = LookupType.Username; diff --git a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs index e13ac8e539..653abf7427 100644 --- a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs @@ -8,7 +8,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/Responses/APIScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APIScore.cs similarity index 96% rename from osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs rename to osu.Game/Online/API/Requests/Responses/APIScore.cs index 467d5a9f23..4f795bee6c 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScore.cs @@ -13,10 +13,11 @@ 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 APIScoreInfo : IScoreInfo + public class APIScore : IScoreInfo { [JsonProperty(@"score")] public long TotalScore { get; set; } @@ -101,7 +102,7 @@ namespace osu.Game.Online.API.Requests.Responses BeatmapInfo = beatmap, User = User, Accuracy = Accuracy, - OnlineScoreID = OnlineID, + OnlineID = OnlineID, Date = Date, PP = PP, RulesetID = RulesetID, @@ -150,6 +151,11 @@ namespace osu.Game.Online.API.Requests.Responses 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 48b7134901..d3c9ba0c7e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs @@ -14,7 +14,7 @@ namespace osu.Game.Online.API.Requests.Responses public int? Position; [JsonProperty(@"score")] - public APIScoreInfo Score; + public APIScore Score; public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null) { diff --git a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs index 5304664bf8..283ebf2411 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests.Responses public class APIScoresCollection { [JsonProperty(@"scores")] - public List Scores; + public List Scores; [JsonProperty(@"userScore")] public APIScoreWithPosition UserScore; diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 50f5d67796..e4a432b074 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -152,10 +152,10 @@ namespace osu.Game.Online.API.Requests.Responses public int ScoresRecentCount; [JsonProperty(@"beatmap_playcounts_count")] - public int BeatmapPlaycountsCount; + public int BeatmapPlayCountsCount; - [JsonProperty] - private string[] playstyle + [JsonProperty(@"playstyle")] + private string[] playStyle { set => PlayStyles = value?.Select(str => Enum.Parse(typeof(APIPlayStyle), str, true)).Cast().ToArray(); } @@ -213,7 +213,7 @@ namespace osu.Game.Online.API.Requests.Responses public APIUserAchievement[] Achievements; [JsonProperty("monthly_playcounts")] - public APIUserHistoryCount[] MonthlyPlaycounts; + public APIUserHistoryCount[] MonthlyPlayCounts; [JsonProperty("replays_watched_counts")] public APIUserHistoryCount[] ReplaysWatchedCounts; diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index edaf135e93..77b52c34d9 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -7,8 +7,10 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Game.Database; +using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -67,11 +69,37 @@ namespace osu.Game.Online.Chat public readonly BindableBool HighPollRate = new BindableBool(); + private readonly IBindable isIdle = new BindableBool(); + public ChannelManager() { CurrentChannel.ValueChanged += currentChannelChanged; + } - HighPollRate.BindValueChanged(enabled => TimeBetweenPolls.Value = enabled.NewValue ? 1000 : 6000, true); + [BackgroundDependencyLoader(permitNulls: true)] + private void load(IdleTracker idleTracker) + { + HighPollRate.BindValueChanged(updatePollRate); + isIdle.BindValueChanged(updatePollRate, true); + + if (idleTracker != null) + isIdle.BindTo(idleTracker.IsIdle); + } + + private void updatePollRate(ValueChangedEvent valueChangedEvent) + { + // Polling will eventually be replaced with websocket, but let's avoid doing these background operations as much as possible for now. + // The only loss will be delayed PM/message highlight notifications. + int millisecondsBetweenPolls = HighPollRate.Value ? 1000 : 60000; + + if (isIdle.Value) + millisecondsBetweenPolls *= 10; + + if (TimeBetweenPolls.Value != millisecondsBetweenPolls) + { + TimeBetweenPolls.Value = millisecondsBetweenPolls; + Logger.Log($"Chat is now polling every {TimeBetweenPolls.Value} ms"); + } } /// @@ -335,7 +363,7 @@ namespace osu.Game.Online.Chat /// right now it caps out at 50 messages and therefore only returns one channel's worth of content. /// /// The channel - private void fetchInitalMessages(Channel channel) + private void fetchInitialMessages(Channel channel) { if (channel.Id <= 0 || channel.MessagesLoaded) return; @@ -441,7 +469,7 @@ namespace osu.Game.Online.Chat else { if (fetchInitialMessages) - fetchInitalMessages(channel); + this.fetchInitialMessages(channel); } CurrentChannel.Value ??= channel; @@ -509,11 +537,12 @@ namespace osu.Game.Online.Chat else if (lastClosedChannel.Type == ChannelType.PM) { // Try to get user in order to open PM chat - users.GetUserAsync((int)lastClosedChannel.Id).ContinueWith(u => + users.GetUserAsync((int)lastClosedChannel.Id).ContinueWith(task => { - if (u.Result == null) return; + var user = task.GetResultSafely(); - Schedule(() => CurrentChannel.Value = JoinChannel(new Channel(u.Result))); + if (user != null) + Schedule(() => CurrentChannel.Value = JoinChannel(new Channel(user))); }); } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 92911f0f51..d7974004b1 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -57,6 +57,7 @@ namespace osu.Game.Online.Chat /// public static string WebsiteRootUrl { + get => websiteRootUrl; set => websiteRootUrl = value .Trim('/') // trim potential trailing slash/ .Split('/').Last(); // only keep domain name, ignoring protocol. @@ -134,7 +135,7 @@ namespace osu.Game.Online.Chat case "http": case "https": // length > 3 since all these links need another argument to work - if (args.Length > 3 && args[1].EndsWith(websiteRootUrl, StringComparison.OrdinalIgnoreCase)) + if (args.Length > 3 && args[1].EndsWith(WebsiteRootUrl, StringComparison.OrdinalIgnoreCase)) { string mainArg = args[3]; @@ -262,7 +263,7 @@ namespace osu.Game.Online.Chat handleMatches(old_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '(', ')' }); // handle wiki links - handleMatches(wiki_regex, "{1}", "https://osu.ppy.sh/wiki/{1}", result, startIndex); + handleMatches(wiki_regex, "{1}", $"https://{WebsiteRootUrl}/wiki/{{1}}", result, startIndex); // handle bare links handleAdvanced(advanced_link_regex, result, startIndex); diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index ca6317566f..2c99e9f9b9 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.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. -using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using System.Text.RegularExpressions; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -120,16 +120,21 @@ namespace osu.Game.Online.Chat private void checkForMentions(Channel channel, Message message) { - if (!notifyOnUsername.Value || !checkContainsUsername(message.Content, localUser.Value.Username)) return; + if (!notifyOnUsername.Value || !CheckContainsUsername(message.Content, localUser.Value.Username)) return; notifications.Post(new MentionNotification(message.Sender.Username, channel)); } /// - /// Checks if contains . + /// Checks if mentions . /// This will match against the case where underscores are used instead of spaces (which is how osu-stable handles usernames with spaces). /// - private static bool checkContainsUsername(string message, string username) => message.Contains(username, StringComparison.OrdinalIgnoreCase) || message.Contains(username.Replace(' ', '_'), StringComparison.OrdinalIgnoreCase); + public static bool CheckContainsUsername(string message, string username) + { + string fullName = Regex.Escape(username); + string underscoreName = Regex.Escape(username.Replace(' ', '_')); + return Regex.IsMatch(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); + } public class PrivateMessageNotification : OpenChannelNotification { @@ -165,7 +170,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay, ChannelManager channelManager) { - IconBackgound.Colour = colours.PurpleDark; + IconBackground.Colour = colours.PurpleDark; Activated = delegate { diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 1f8d05fcd1..f83bf4877e 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -23,27 +23,27 @@ namespace osu.Game.Online.Chat { public readonly Bindable Channel = new Bindable(); - protected readonly ChatTextBox Textbox; + protected readonly ChatTextBox TextBox; - protected ChannelManager ChannelManager; + private ChannelManager channelManager; private StandAloneDrawableChannel drawableChannel; - private readonly bool postingTextbox; + private readonly bool postingTextBox; protected readonly Box Background; - private const float textbox_height = 30; + private const float text_box_height = 30; /// /// Construct a new instance. /// - /// Whether a textbox for posting new messages should be displayed. - public StandAloneChatDisplay(bool postingTextbox = false) + /// Whether a textbox for posting new messages should be displayed. + public StandAloneChatDisplay(bool postingTextBox = false) { const float corner_radius = 10; - this.postingTextbox = postingTextbox; + this.postingTextBox = postingTextBox; CornerRadius = corner_radius; Masking = true; @@ -57,12 +57,12 @@ namespace osu.Game.Online.Chat }, }; - if (postingTextbox) + if (postingTextBox) { - AddInternal(Textbox = new ChatTextBox + AddInternal(TextBox = new ChatTextBox { RelativeSizeAxes = Axes.X, - Height = textbox_height, + Height = text_box_height, PlaceholderText = "type your message", CornerRadius = corner_radius, ReleaseFocusOnCommit = false, @@ -71,7 +71,7 @@ namespace osu.Game.Online.Chat Origin = Anchor.BottomLeft, }); - Textbox.OnCommit += postMessage; + TextBox.OnCommit += postMessage; } Channel.BindValueChanged(channelChanged); @@ -80,25 +80,25 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader(true)] private void load(ChannelManager manager) { - ChannelManager ??= manager; + channelManager ??= manager; } protected virtual StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => new StandAloneDrawableChannel(channel); - private void postMessage(TextBox sender, bool newtext) + private void postMessage(TextBox sender, bool newText) { - string text = Textbox.Text.Trim(); + string text = TextBox.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) return; if (text[0] == '/') - ChannelManager?.PostCommand(text.Substring(1), Channel.Value); + channelManager?.PostCommand(text.Substring(1), Channel.Value); else - ChannelManager?.PostMessage(text, target: Channel.Value); + channelManager?.PostMessage(text, target: Channel.Value); - Textbox.Text = string.Empty; + TextBox.Text = string.Empty; } protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message); @@ -111,7 +111,7 @@ namespace osu.Game.Online.Chat drawableChannel = CreateDrawableChannel(e.NewValue); drawableChannel.CreateChatLineAction = CreateMessage; - drawableChannel.Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 }; + drawableChannel.Padding = new MarginPadding { Bottom = postingTextBox ? text_box_height : 0 }; AddInternal(drawableChannel); } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index e01c7c9e49..14eec8b388 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -65,9 +65,6 @@ namespace osu.Game.Online.Leaderboards [Resolved(CanBeNull = true)] private SongSelect songSelect { get; set; } - [Resolved] - private ScoreManager scoreManager { get; set; } - [Resolved] private Storage storage { get; set; } @@ -114,7 +111,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = user.Id == api.LocalUser.Value.Id && allowHighlight ? colour.Green : Color4.Black, + Colour = user.OnlineID == api.LocalUser.Value.Id && allowHighlight ? colour.Green : Color4.Black, Alpha = background_alpha, }, }, diff --git a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs index ab4210251e..3db497bd6a 100644 --- a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs +++ b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs @@ -3,13 +3,11 @@ using System; using System.Threading; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets; using osuTK; namespace osu.Game.Online.Leaderboards @@ -25,9 +23,6 @@ namespace osu.Game.Online.Leaderboards protected override bool StartHidden => true; - [Resolved] - private RulesetStore rulesets { get; set; } - public UserTopScoreContainer(Func createScoreDelegate) { this.createScoreDelegate = createScoreDelegate; diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 3e84e4b904..073d512f90 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -77,10 +77,27 @@ namespace osu.Game.Online.Multiplayer /// If an attempt to start the game occurs when the game's (or users') state disallows it. Task StartMatch(); + /// + /// Aborts an ongoing gameplay load. + /// + Task AbortGameplay(); + /// /// Adds an item to the playlist. /// /// The item to add. Task AddPlaylistItem(MultiplayerPlaylistItem item); + + /// + /// Edits an existing playlist item with new values. + /// + /// The item to edit, containing new properties. Must have an ID. + Task EditPlaylistItem(MultiplayerPlaylistItem item); + + /// + /// Removes an item from the playlist. + /// + /// The item to remove. + Task RemovePlaylistItem(long playlistItemId); } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 0822c29376..903aaa89e3 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -32,12 +32,36 @@ namespace osu.Game.Online.Multiplayer /// public event Action? RoomUpdated; + /// + /// Invoked when a new user joins the room. + /// public event Action? UserJoined; + /// + /// Invoked when a user leaves the room of their own accord. + /// public event Action? UserLeft; + /// + /// Invoked when a user was kicked from the room forcefully. + /// public event Action? UserKicked; + /// + /// Invoked when a new item is added to the playlist. + /// + public event Action? ItemAdded; + + /// + /// Invoked when a playlist item is removed from the playlist. The provided long is the playlist's item ID. + /// + public event Action? ItemRemoved; + + /// + /// Invoked when a playlist item's details change. + /// + public event Action? ItemChanged; + /// /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. /// @@ -71,8 +95,6 @@ namespace osu.Game.Online.Multiplayer protected readonly BindableList PlayingUserIds = new BindableList(); - public readonly Bindable CurrentMatchPlayingItem = new Bindable(); - /// /// The corresponding to the local player, if available. /// @@ -94,7 +116,7 @@ namespace osu.Game.Online.Multiplayer protected IAPIProvider API { get; private set; } = null!; [Resolved] - protected RulesetStore Rulesets { get; private set; } = null!; + protected IRulesetStore Rulesets { get; private set; } = null!; [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; @@ -138,9 +160,6 @@ namespace osu.Game.Online.Multiplayer var joinedRoom = await JoinRoom(room.RoomID.Value.Value, password ?? room.Password.Value).ConfigureAwait(false); Debug.Assert(joinedRoom != null); - // Populate playlist items. - var playlistItems = await Task.WhenAll(joinedRoom.Playlist.Select(createPlaylistItem)).ConfigureAwait(false); - // Populate users. Debug.Assert(joinedRoom.Users != null); await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); @@ -152,7 +171,7 @@ namespace osu.Game.Online.Multiplayer APIRoom = room; APIRoom.Playlist.Clear(); - APIRoom.Playlist.AddRange(playlistItems); + APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); Debug.Assert(LocalUser != null); addUserToAPIRoom(LocalUser); @@ -195,7 +214,6 @@ namespace osu.Game.Online.Multiplayer { APIRoom = null; Room = null; - CurrentMatchPlayingItem.Value = null; PlayingUserIds.Clear(); RoomUpdated?.Invoke(); @@ -309,8 +327,14 @@ namespace osu.Game.Online.Multiplayer public abstract Task StartMatch(); + public abstract Task AbortGameplay(); + public abstract Task AddPlaylistItem(MultiplayerPlaylistItem item); + public abstract Task EditPlaylistItem(MultiplayerPlaylistItem item); + + public abstract Task RemovePlaylistItem(long playlistItemId); + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { if (Room == null) @@ -446,7 +470,11 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { + Debug.Assert(APIRoom != null); + Debug.Assert(Room != null); + Scheduler.Add(() => updateLocalRoomSettings(newSettings)); + return Task.CompletedTask; } @@ -600,12 +628,10 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } - public async Task PlaylistItemAdded(MultiplayerPlaylistItem item) + public Task PlaylistItemAdded(MultiplayerPlaylistItem item) { if (Room == null) - return; - - var playlistItem = await createPlaylistItem(item).ConfigureAwait(false); + return Task.CompletedTask; Scheduler.Add(() => { @@ -615,10 +641,13 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(APIRoom != null); Room.Playlist.Add(item); - APIRoom.Playlist.Add(playlistItem); + APIRoom.Playlist.Add(createPlaylistItem(item)); + ItemAdded?.Invoke(item); RoomUpdated?.Invoke(); }); + + return Task.CompletedTask; } public Task PlaylistItemRemoved(long playlistItemId) @@ -636,18 +665,17 @@ namespace osu.Game.Online.Multiplayer Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId)); APIRoom.Playlist.RemoveAll(existing => existing.ID == playlistItemId); + ItemRemoved?.Invoke(playlistItemId); RoomUpdated?.Invoke(); }); return Task.CompletedTask; } - public async Task PlaylistItemChanged(MultiplayerPlaylistItem item) + public Task PlaylistItemChanged(MultiplayerPlaylistItem item) { if (Room == null) - return; - - var playlistItem = await createPlaylistItem(item).ConfigureAwait(false); + return Task.CompletedTask; Scheduler.Add(() => { @@ -660,14 +688,13 @@ namespace osu.Game.Online.Multiplayer int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); APIRoom.Playlist.RemoveAt(existingIndex); - APIRoom.Playlist.Insert(existingIndex, playlistItem); - - // If the currently-selected item was the one that got replaced, update the selected item to the new one. - if (CurrentMatchPlayingItem.Value?.ID == playlistItem.ID) - CurrentMatchPlayingItem.Value = playlistItem; + APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + ItemChanged?.Invoke(item); RoomUpdated?.Invoke(); }); + + return Task.CompletedTask; } /// @@ -696,25 +723,27 @@ namespace osu.Game.Online.Multiplayer APIRoom.Password.Value = Room.Settings.Password; APIRoom.Type.Value = Room.Settings.MatchType; APIRoom.QueueMode.Value = Room.Settings.QueueMode; - RoomUpdated?.Invoke(); - CurrentMatchPlayingItem.Value = APIRoom.Playlist.SingleOrDefault(p => p.ID == settings.PlaylistItemId); + RoomUpdated?.Invoke(); } - private async Task createPlaylistItem(MultiplayerPlaylistItem item) + private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) { - var apiBeatmap = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false); - var ruleset = Rulesets.GetRuleset(item.RulesetID); + + Debug.Assert(ruleset != null); + var rulesetInstance = ruleset.CreateInstance(); var playlistItem = new PlaylistItem { ID = item.ID, + BeatmapID = item.BeatmapID, OwnerID = item.OwnerID, - Beatmap = { Value = apiBeatmap }, Ruleset = { Value = ruleset }, - Expired = item.Expired + Expired = item.Expired, + PlaylistOrder = item.PlaylistOrder, + PlayedAt = item.PlayedAt }; playlistItem.RequiredMods.AddRange(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))); @@ -729,7 +758,7 @@ namespace osu.Game.Online.Multiplayer /// The beatmap ID. /// A token to cancel the request. /// The retrieval task. - protected abstract Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default); + public abstract Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default); /// /// For the provided user ID, update whether the user is included in . diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 41687b54b0..3794bec228 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -154,6 +154,14 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); } + public override Task AbortGameplay() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.AbortGameplay)); + } + public override Task AddPlaylistItem(MultiplayerPlaylistItem item) { if (!IsConnected.Value) @@ -162,7 +170,23 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.AddPlaylistItem), item); } - protected override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) + public override Task EditPlaylistItem(MultiplayerPlaylistItem item) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.EditPlaylistItem), item); + } + + public override Task RemovePlaylistItem(long playlistItemId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.RemovePlaylistItem), playlistItemId); + } + + public override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) { return beatmapLookupCache.GetBeatmapAsync(beatmapId, cancellationToken); } diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index 6ca0b822f3..8ec073ff1e 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -39,6 +39,22 @@ namespace osu.Game.Online.Rooms [Key(7)] public bool Expired { get; set; } + /// + /// The order in which this will be played relative to others. + /// Playlist items should be played in increasing order (lower values are played first). + /// + /// + /// This is only valid for items which are not . The value for expired items is undefined and should not be used. + /// + [Key(8)] + public ushort PlaylistOrder { get; set; } + + /// + /// The date when this was played. + /// + [Key(9)] + public DateTimeOffset? PlayedAt { get; set; } + public MultiplayerPlaylistItem() { } @@ -46,12 +62,15 @@ namespace osu.Game.Online.Rooms public MultiplayerPlaylistItem(PlaylistItem item) { ID = item.ID; + OwnerID = item.OwnerID; BeatmapID = item.BeatmapID; BeatmapChecksum = item.Beatmap.Value?.MD5Hash ?? string.Empty; RulesetID = item.RulesetID; RequiredMods = item.RequiredMods.Select(m => new APIMod(m)).ToArray(); AllowedMods = item.AllowedMods.Select(m => new APIMod(m)).ToArray(); Expired = item.Expired; + PlaylistOrder = item.PlaylistOrder ?? 0; + PlayedAt = item.PlayedAt; } } } diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 7bc3377ad9..05c9a1b6cf 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -69,7 +69,7 @@ namespace osu.Game.Online.Rooms var scoreInfo = new ScoreInfo { - OnlineScoreID = ID, + OnlineID = ID, TotalScore = TotalScore, MaxCombo = MaxCombo, BeatmapInfo = beatmap, diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index aa0e37363b..a32f069470 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -40,6 +40,11 @@ namespace osu.Game.Online.Rooms private BeatmapDownloadTracker downloadTracker; + /// + /// The beatmap matching the required hash (and providing a final state). + /// + private BeatmapInfo matchingHash; + protected override void LoadComplete() { base.LoadComplete(); @@ -71,13 +76,34 @@ namespace osu.Game.Online.Rooms progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500); }, true); }, true); + + // These events are needed for a fringe case where a modified/altered beatmap is imported with matching OnlineIDs. + // During the import process this will cause the existing beatmap set to be silently deleted and replaced with the new one. + // This is not exposed to us via `BeatmapDownloadTracker` so we have to take it into our own hands (as we care about the hash matching). + beatmapManager.ItemUpdated += itemUpdated; + beatmapManager.ItemRemoved += itemRemoved; } + private void itemUpdated(BeatmapSetInfo item) => Schedule(() => + { + if (matchingHash?.BeatmapSet.ID == item.ID || SelectedItem.Value?.Beatmap.Value.BeatmapSet?.OnlineID == item.OnlineID) + updateAvailability(); + }); + + private void itemRemoved(BeatmapSetInfo item) => Schedule(() => + { + if (matchingHash?.BeatmapSet.ID == item.ID) + updateAvailability(); + }); + private void updateAvailability() { if (downloadTracker == null) return; + // will be repopulated below if still valid. + matchingHash = null; + switch (downloadTracker.State.Value) { case DownloadState.NotDownloaded: @@ -93,7 +119,9 @@ namespace osu.Game.Online.Rooms break; case DownloadState.LocallyAvailable: - bool hashMatches = checkHashValidity(); + matchingHash = findMatchingHash(); + + bool hashMatches = matchingHash != null; availability.Value = hashMatches ? BeatmapAvailability.LocallyAvailable() : BeatmapAvailability.NotDownloaded(); @@ -108,12 +136,23 @@ namespace osu.Game.Online.Rooms } } - private bool checkHashValidity() + private BeatmapInfo findMatchingHash() { int onlineId = SelectedItem.Value.Beatmap.Value.OnlineID; string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; - return beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId && b.MD5Hash == checksum && !b.BeatmapSet.DeletePending) != null; + return beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId && b.MD5Hash == checksum && !b.BeatmapSet.DeletePending); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (beatmapManager != null) + { + beatmapManager.ItemUpdated -= itemUpdated; + beatmapManager.ItemRemoved -= itemRemoved; + } } } } diff --git a/osu.Game/Online/Rooms/PlaylistExtensions.cs b/osu.Game/Online/Rooms/PlaylistExtensions.cs index 992011da3c..e78f91f20b 100644 --- a/osu.Game/Online/Rooms/PlaylistExtensions.cs +++ b/osu.Game/Online/Rooms/PlaylistExtensions.cs @@ -1,6 +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 enable + +using System.Collections.Generic; using System.Linq; using Humanizer; using Humanizer.Localisation; @@ -10,6 +13,33 @@ namespace osu.Game.Online.Rooms { public static class PlaylistExtensions { + /// + /// Returns all historical/expired items from the , in the order in which they were played. + /// + public static IEnumerable GetHistoricalItems(this IEnumerable playlist) + => playlist.Where(item => item.Expired).OrderBy(item => item.PlayedAt); + + /// + /// Returns all non-expired items from the , in the order in which they are to be played. + /// + public static IEnumerable GetUpcomingItems(this IEnumerable playlist) + => playlist.Where(item => !item.Expired).OrderBy(item => item.PlaylistOrder); + + /// + /// Returns the first non-expired in playlist order from the supplied , + /// or the last-played if all items are expired, + /// or if was empty. + /// + public static PlaylistItem? GetCurrentItem(this ICollection playlist) + { + if (playlist.Count == 0) + return null; + + return playlist.All(item => item.Expired) + ? GetHistoricalItems(playlist).Last() + : GetUpcomingItems(playlist).First(); + } + public static string GetTotalDuration(this BindableList playlist) => playlist.Select(p => p.Beatmap.Value.Length).Sum().Milliseconds().Humanize(minUnit: TimeUnit.Second, maxUnit: TimeUnit.Hour, precision: 2); } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index a1480865b8..83a70c405b 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -2,7 +2,9 @@ // 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.Bindables; using osu.Game.Beatmaps; @@ -33,6 +35,12 @@ namespace osu.Game.Online.Rooms [JsonProperty("expired")] public bool Expired { get; set; } + [JsonProperty("playlist_order")] + public ushort? PlaylistOrder { get; set; } + + [JsonProperty("played_at")] + public DateTimeOffset? PlayedAt { get; set; } + [JsonIgnore] public IBindable Valid => valid; @@ -79,11 +87,13 @@ namespace osu.Game.Online.Rooms public void MarkInvalid() => valid.Value = false; - public void MapObjects(RulesetStore rulesets) + public void MapObjects(IRulesetStore rulesets) { Beatmap.Value ??= apiBeatmap; Ruleset.Value ??= rulesets.GetRuleset(RulesetID); + Debug.Assert(Ruleset.Value != null); + Ruleset rulesetInstance = Ruleset.Value.CreateInstance(); if (allowedModsBacking != null) @@ -103,9 +113,21 @@ namespace osu.Game.Online.Rooms } } + #region Newtonsoft.Json implicit ShouldSerialize() methods + + // The properties in this region are used implicitly by Newtonsoft.Json to not serialise certain fields in some cases. + // They rely on being named exactly the same as the corresponding fields (casing included) and as such should NOT be renamed + // unless the fields are also renamed. + + [UsedImplicitly] public bool ShouldSerializeID() => false; + + // ReSharper disable once IdentifierTypo + [UsedImplicitly] public bool ShouldSerializeapiBeatmap() => false; + #endregion + public bool Equals(PlaylistItem other) => ID == other?.ID && BeatmapID == other.BeatmapID diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index c87411c3c0..bbe854f2dd 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -211,8 +212,21 @@ namespace osu.Game.Online.Rooms Playlist.RemoveAll(i => i.Expired); } + #region Newtonsoft.Json implicit ShouldSerialize() methods + + // The properties in this region are used implicitly by Newtonsoft.Json to not serialise certain fields in some cases. + // They rely on being named exactly the same as the corresponding fields (casing included) and as such should NOT be renamed + // unless the fields are also renamed. + + [UsedImplicitly] public bool ShouldSerializeRoomID() => false; + + [UsedImplicitly] public bool ShouldSerializeHost() => false; + + [UsedImplicitly] public bool ShouldSerializeEndDate() => false; + + #endregion } } diff --git a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs index d5da6c401c..e24d113822 100644 --- a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs @@ -31,6 +31,7 @@ namespace osu.Game.Online.Rooms req.ContentType = "application/json"; req.Method = HttpMethod.Put; + req.Timeout = 30000; req.AddRaw(JsonConvert.SerializeObject(score, new JsonSerializerSettings { diff --git a/osu.Game/Online/ScoreDownloadTracker.cs b/osu.Game/Online/ScoreDownloadTracker.cs index e09cc7c9cd..68932cc388 100644 --- a/osu.Game/Online/ScoreDownloadTracker.cs +++ b/osu.Game/Online/ScoreDownloadTracker.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Scoring; @@ -35,7 +36,7 @@ namespace osu.Game.Online var scoreInfo = new ScoreInfo { ID = TrackedItem.ID, - OnlineScoreID = TrackedItem.OnlineScoreID + OnlineID = TrackedItem.OnlineID }; if (Manager.IsAvailableLocally(scoreInfo)) @@ -113,7 +114,7 @@ namespace osu.Game.Online UpdateState(DownloadState.NotDownloaded); }); - private bool checkEquality(IScoreInfo x, IScoreInfo y) => x.OnlineID == y.OnlineID; + private bool checkEquality(IScoreInfo x, IScoreInfo y) => x.MatchesOnlineID(y); #region Disposal diff --git a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs index 25c2e5a61f..78ebddb2e6 100644 --- a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs +++ b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs @@ -12,17 +12,17 @@ namespace osu.Game.Online.Solo { public class SubmitSoloScoreRequest : APIRequest { + public readonly SubmittableScore Score; + private readonly long scoreId; private readonly int beatmapId; - private readonly SubmittableScore score; - public SubmitSoloScoreRequest(int beatmapId, long scoreId, ScoreInfo scoreInfo) { this.beatmapId = beatmapId; this.scoreId = scoreId; - score = new SubmittableScore(scoreInfo); + Score = new SubmittableScore(scoreInfo); } protected override WebRequest CreateWebRequest() @@ -31,8 +31,9 @@ namespace osu.Game.Online.Solo req.ContentType = "application/json"; req.Method = HttpMethod.Put; + req.Timeout = 30000; - req.AddRaw(JsonConvert.SerializeObject(score, new JsonSerializerSettings + req.AddRaw(JsonConvert.SerializeObject(Score, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore })); diff --git a/osu.Game/Online/Solo/SubmittableScore.cs b/osu.Game/Online/Solo/SubmittableScore.cs index 373c302844..5ca5ad9619 100644 --- a/osu.Game/Online/Solo/SubmittableScore.cs +++ b/osu.Game/Online/Solo/SubmittableScore.cs @@ -16,7 +16,7 @@ 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. + /// This is used instead of due to marginally different serialisation naming requirements. /// [Serializable] public class SubmittableScore diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 6b95d288c5..4da9bace70 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -136,30 +136,32 @@ namespace osu.Game.Online.Spectator public void BeginPlaying(GameplayState state, Score score) { - Debug.Assert(ThreadSafety.IsUpdateThread); + // This schedule is only here to match the one below in `EndPlaying`. + Schedule(() => + { + if (IsPlaying) + throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); - if (IsPlaying) - throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); + IsPlaying = true; - IsPlaying = true; + // transfer state at point of beginning play + currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID; + currentState.RulesetID = score.ScoreInfo.RulesetID; + currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); - // transfer state at point of beginning play - currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID; - currentState.RulesetID = score.ScoreInfo.RulesetID; - currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); + currentBeatmap = state.Beatmap; + currentScore = score; - currentBeatmap = state.Beatmap; - currentScore = score; - - BeginPlayingInternal(currentState); + BeginPlayingInternal(currentState); + }); } public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data); public void EndPlaying() { - // This method is most commonly called via Dispose(), which is asynchronous. - // Todo: This should not be a thing, but requires framework changes. + // This method is most commonly called via Dispose(), which is can be asynchronous (via the AsyncDisposalQueue). + // We probably need to find a better way to handle this... Schedule(() => { if (!IsPlaying) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 99b67976e3..21d84a365b 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -103,7 +103,7 @@ namespace osu.Game private Container topMostOverlayContent; - private ScalingContainer screenContainer; + protected ScalingContainer ScreenContainer { get; private set; } protected Container ScreenOffsetContainer { get; private set; } @@ -161,7 +161,7 @@ namespace osu.Game private Bindable uiScale; - private Bindable configSkin; + private Bindable configSkin; private readonly string[] args; @@ -179,7 +179,7 @@ namespace osu.Game } private void updateBlockingOverlayFade() => - screenContainer.FadeColour(visibleBlockingOverlays.Any() ? OsuColour.Gray(0.5f) : Color4.White, 500, Easing.OutQuint); + ScreenContainer.FadeColour(visibleBlockingOverlays.Any() ? OsuColour.Gray(0.5f) : Color4.White, 500, Easing.OutQuint); public void AddBlockingOverlay(OverlayContainer overlay) { @@ -243,27 +243,22 @@ namespace osu.Game Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName; // bind config int to database SkinInfo - configSkin = LocalConfig.GetBindable(OsuSetting.Skin); - SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID; + configSkin = LocalConfig.GetBindable(OsuSetting.Skin); + SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); configSkin.ValueChanged += skinId => { - var skinInfo = SkinManager.Query(s => s.ID == skinId.NewValue); + ILive skinInfo = null; + + if (Guid.TryParse(skinId.NewValue, out var guid)) + skinInfo = SkinManager.Query(s => s.ID == guid); if (skinInfo == null) { - switch (skinId.NewValue) - { - case -1: - skinInfo = DefaultLegacySkin.Info; - break; - - default: - skinInfo = SkinInfo.Default; - break; - } + if (guid == SkinInfo.CLASSIC_SKIN) + skinInfo = DefaultLegacySkin.CreateInfo().ToLiveUnmanaged(); } - SkinManager.CurrentSkinInfo.Value = skinInfo; + SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLiveUnmanaged(); }; configSkin.TriggerChange(); @@ -492,8 +487,8 @@ namespace osu.Game // to ensure all the required data for presenting a replay are present. ScoreInfo databasedScoreInfo = null; - if (score.OnlineScoreID != null) - databasedScoreInfo = ScoreManager.Query(s => s.OnlineScoreID == score.OnlineScoreID); + if (score.OnlineID > 0) + databasedScoreInfo = ScoreManager.Query(s => s.OnlineID == score.OnlineID); databasedScoreInfo ??= ScoreManager.Query(s => s.Hash == score.Hash); @@ -664,7 +659,7 @@ namespace osu.Game // 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.GetAllUsableSkins().FirstOrDefault(s => s.ID == id)?.ToString() ?? "Unknown"; + LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; LocalConfig.LookupKeyBindings = l => { @@ -685,7 +680,7 @@ namespace osu.Game sessionIdleTracker.IsIdle.BindValueChanged(idle => { if (idle.NewValue) - SessionStatics.ResetValues(); + SessionStatics.ResetAfterInactivity(); }); Add(sessionIdleTracker); @@ -703,7 +698,7 @@ namespace osu.Game RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - screenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) + ScreenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -806,7 +801,7 @@ namespace osu.Game loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); - loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add, true); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { @@ -825,7 +820,17 @@ namespace osu.Game loadComponentSingleFile(CreateHighPerformanceSession(), Add); - chatOverlay.State.ValueChanged += state => channelManager.HighPollRate.Value = state.NewValue == Visibility.Visible; + chatOverlay.State.BindValueChanged(_ => updateChatPollRate()); + // Multiplayer modes need to increase poll rate temporarily. + API.Activity.BindValueChanged(_ => updateChatPollRate(), true); + + void updateChatPollRate() + { + channelManager.HighPollRate.Value = + chatOverlay.State.Value == Visibility.Visible + || API.Activity.Value is UserActivity.InLobby + || API.Activity.Value is UserActivity.InMultiplayerGame; + } Add(difficultyRecommender); Add(externalLinkOpener = new ExternalLinkOpener()); @@ -1154,16 +1159,11 @@ namespace osu.Game } } - private void screenPushed(IScreen lastScreen, IScreen newScreen) - { - ScreenChanged(lastScreen, newScreen); - Logger.Log($"Screen changed → {newScreen}"); - } + private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged(lastScreen, newScreen); private void screenExited(IScreen lastScreen, IScreen newScreen) { ScreenChanged(lastScreen, newScreen); - Logger.Log($"Screen changed ← {newScreen}"); if (newScreen == null) Exit(); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 34344f8022..9256514a0a 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -196,9 +196,12 @@ namespace osu.Game runMigrations(); dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage)); + dependencies.CacheAs(RulesetStore); dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", contextFactory)); + new EFToRealmMigrator(contextFactory, realmFactory, LocalConfig).Run(); + dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); @@ -212,17 +215,9 @@ namespace osu.Game Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; - dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio)); + dependencies.Cache(SkinManager = new SkinManager(Storage, realmFactory, Host, Resources, Audio, Scheduler)); dependencies.CacheAs(SkinManager); - // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. - SkinManager.ItemRemoved += item => Schedule(() => - { - // check the removed skin is not the current user choice. if it is, switch back to default. - if (item.Equals(SkinManager.CurrentSkinInfo.Value)) - SkinManager.CurrentSkinInfo.Value = SkinInfo.Default; - }); - EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; @@ -273,7 +268,7 @@ namespace osu.Game dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); - dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(realmFactory, RulesetStore)); + dependencies.CacheAs(rulesetConfigCache = new RulesetConfigCache(realmFactory, RulesetStore)); var powerStatus = CreateBatteryInfo(); if (powerStatus != null) diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index 2ba8fc3ae2..a2c04c6989 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -44,7 +44,7 @@ namespace osu.Game.Overlays.AccountCreation private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { InternalChildren = new Drawable[] { @@ -152,12 +152,12 @@ namespace osu.Game.Overlays.AccountCreation loadingLayer.Hide(); if (host?.OnScreenKeyboardOverlapsGameWindow != true) - focusNextTextbox(); + focusNextTextBox(); } private void performRegistration() { - if (focusNextTextbox()) + if (focusNextTextBox()) { registerShake.Shake(); return; @@ -209,19 +209,19 @@ namespace osu.Game.Overlays.AccountCreation }); } - private bool focusNextTextbox() + private bool focusNextTextBox() { - var nextTextbox = nextUnfilledTextbox(); + var nextTextBox = nextUnfilledTextBox(); - if (nextTextbox != null) + if (nextTextBox != null) { - Schedule(() => GetContainingInputManager().ChangeFocus(nextTextbox)); + Schedule(() => GetContainingInputManager().ChangeFocus(nextTextBox)); return true; } return false; } - private OsuTextBox nextUnfilledTextbox() => textboxes.FirstOrDefault(t => string.IsNullOrEmpty(t.Text)); + private OsuTextBox nextUnfilledTextBox() => textboxes.FirstOrDefault(t => string.IsNullOrEmpty(t.Text)); } } diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index 3084c7475a..a96aff2a5d 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Framework.Threading; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Overlays.AccountCreation; @@ -35,7 +34,7 @@ namespace osu.Game.Overlays private readonly IBindable apiState = new Bindable(); [BackgroundDependencyLoader] - private void load(OsuColour colours, IAPIProvider api) + private void load(IAPIProvider api) { apiState.BindTo(api.State); apiState.BindValueChanged(apiStateChanged, true); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs new file mode 100644 index 0000000000..e4fda9d9c3 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs @@ -0,0 +1,134 @@ +// 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.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingCardSizeTabControl : OsuTabControl + { + public BeatmapListingCardSizeTabControl() + { + AutoSizeAxes = Axes.Both; + } + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + }; + + protected override Dropdown CreateDropdown() => null; + + protected override TabItem CreateTabItem(BeatmapCardSize value) => new TabItem(value); + + private class TabItem : TabItem + { + private Box background; + private SpriteIcon icon; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public TabItem(BeatmapCardSize value) + : base(value) + { + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 4; + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3 + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 10, + Vertical = 5, + }, + Child = icon = new SpriteIcon + { + Size = new Vector2(12), + Icon = getIconForCardSize(Value) + } + } + }; + } + + private static IconUsage getIconForCardSize(BeatmapCardSize cardSize) + { + switch (cardSize) + { + case BeatmapCardSize.Normal: + return FontAwesome.Solid.Th; + + case BeatmapCardSize.Extra: + return FontAwesome.Solid.ThLarge; + + default: + throw new ArgumentOutOfRangeException(nameof(cardSize), cardSize, "Unsupported card size"); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + FinishTransforms(true); + } + + protected override void OnActivated() + { + if (IsLoaded) + updateState(); + } + + protected override void OnDeactivated() + { + if (IsLoaded) + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private const double fade_time = 200; + + private void updateState() + { + background.FadeTo(IsHovered || Active.Value ? 1 : 0, fade_time, Easing.OutQuint); + icon.FadeColour(Active.Value && !IsHovered ? colourProvider.Light1 : colourProvider.Content1, fade_time, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 38f2bdb34f..157753c09f 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,10 +13,10 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Threading; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -49,6 +50,11 @@ namespace osu.Game.Overlays.BeatmapListing /// public int CurrentPage { get; private set; } + /// + /// The currently selected . + /// + public IBindable CardSize { get; } = new Bindable(); + private readonly BeatmapListingSearchControl searchControl; private readonly BeatmapListingSortTabControl sortControl; private readonly Box sortControlBackground; @@ -61,9 +67,6 @@ namespace osu.Game.Overlays.BeatmapListing [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - public BeatmapListingFilterControl() { RelativeSizeAxes = Axes.X; @@ -109,6 +112,13 @@ namespace osu.Game.Overlays.BeatmapListing Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Margin = new MarginPadding { Left = 20 } + }, + new BeatmapListingCardSizeTabControl + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 20 }, + Current = { BindTarget = CardSize } } } } @@ -231,12 +241,14 @@ namespace osu.Game.Overlays.BeatmapListing if (filters.Any()) { - SearchFinished?.Invoke(SearchResult.SupporterOnlyFilters(filters)); + var supporterOnlyFilters = SearchResult.SupporterOnlyFilters(filters); + SearchFinished?.Invoke(supporterOnlyFilters); return; } } - SearchFinished?.Invoke(SearchResult.ResultsReturned(sets)); + var resultsReturned = SearchResult.ResultsReturned(sets); + SearchFinished?.Invoke(resultsReturned); }; api.Queue(getSetsRequest); @@ -300,7 +312,7 @@ namespace osu.Game.Overlays.BeatmapListing public static SearchResult ResultsReturned(List results) => new SearchResult { Type = SearchResultType.ResultsReturned, - Results = results + Results = results, }; public static SearchResult SupporterOnlyFilters(List filters) => new SearchResult diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 49f2f5c211..a8e5201aa3 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -19,6 +19,7 @@ using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapListing; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -78,7 +79,6 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Horizontal = 20 }, Children = new Drawable[] { - foundContent = new FillFlowContainer(), notFoundContent = new NotFoundDrawable(), supporterRequiredContent = new SupporterRequiredDrawable(), } @@ -89,6 +89,12 @@ namespace osu.Game.Overlays }; } + protected override void LoadComplete() + { + base.LoadComplete(); + filterControl.CardSize.BindValueChanged(_ => onCardSizeChanged()); + } + public void ShowWithSearch(string query) { filterControl.Search(query); @@ -114,6 +120,8 @@ namespace osu.Game.Overlays private CancellationTokenSource cancellationToken; + private Task panelLoadTask; + private void onSearchStarted() { cancellationToken?.Cancel(); @@ -124,57 +132,70 @@ namespace osu.Game.Overlays Loading.Show(); } - private Task panelLoadDelegate; - private void onSearchFinished(BeatmapListingFilterControl.SearchResult searchResult) { + cancellationToken?.Cancel(); + if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters) { supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed); - addContentToPlaceholder(supporterRequiredContent); + addContentToResultsArea(supporterRequiredContent); return; } - var newPanels = searchResult.Results.Select(b => new BeatmapCard(b) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }); + var newCards = createCardsFor(searchResult.Results); if (filterControl.CurrentPage == 0) { //No matches case - if (!newPanels.Any()) + if (!newCards.Any()) { - addContentToPlaceholder(notFoundContent); + addContentToResultsArea(notFoundContent); return; } - // spawn new children with the contained so we only clear old content at the last moment. - var content = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Alpha = 0, - Margin = new MarginPadding { Vertical = 15 }, - ChildrenEnumerable = newPanels - }; + var content = createCardContainerFor(newCards); - panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + panelLoadTask = LoadComponentAsync(foundContent = content, addContentToResultsArea, (cancellationToken = new CancellationTokenSource()).Token); } else { - panelLoadDelegate = LoadComponentsAsync(newPanels, loaded => + panelLoadTask = LoadComponentsAsync(newCards, loaded => { lastFetchDisplayedTime = Time.Current; foundContent.AddRange(loaded); loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint)); - }); + }, (cancellationToken = new CancellationTokenSource()).Token); } } - private void addContentToPlaceholder(Drawable content) + private BeatmapCard[] createCardsFor(IEnumerable beatmapSets) => beatmapSets.Select(set => BeatmapCard.Create(set, filterControl.CardSize.Value).With(c => + { + c.Anchor = Anchor.TopCentre; + c.Origin = Anchor.TopCentre; + })).ToArray(); + + private static ReverseChildIDFillFlowContainer createCardContainerFor(IEnumerable newCards) + { + // spawn new children with the contained so we only clear old content at the last moment. + // reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most). + var content = new ReverseChildIDFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Alpha = 0, + Margin = new MarginPadding + { + Vertical = 15, + Bottom = ExpandedContentScrollContainer.HEIGHT + }, + ChildrenEnumerable = newCards + }; + return content; + } + + private void addContentToResultsArea(Drawable content) { Loading.Hide(); lastFetchDisplayedTime = Time.Current; @@ -186,26 +207,41 @@ namespace osu.Game.Overlays if (lastContent != null) { - lastContent.FadeOut(100, Easing.OutQuint); - - // Consider the case when the new content is smaller than the last content. - // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. - // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. - // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. - var sequence = lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); - - if (lastContent != notFoundContent && lastContent != supporterRequiredContent) - sequence.Then().Schedule(() => lastContent.Expire()); + lastContent.FadeOut(); + if (!isPlaceholderContent(lastContent)) + lastContent.Expire(); } if (!content.IsAlive) panelTarget.Add(content); - content.FadeInFromZero(200, Easing.OutQuint); + content.FadeInFromZero(); currentContent = content; - // currentContent may be one of the placeholders, and still have BypassAutoSizeAxes set to Y from the last fade-out. - // restore to the initial state. - currentContent.BypassAutoSizeAxes = Axes.None; + } + + /// + /// Whether is a static placeholder reused multiple times by this overlay. + /// + private bool isPlaceholderContent(Drawable drawable) + => drawable == notFoundContent || drawable == supporterRequiredContent; + + private void onCardSizeChanged() + { + if (foundContent?.IsAlive != true || !foundContent.Any()) + return; + + Loading.Show(); + + var newCards = createCardsFor(foundContent.Reverse().Select(card => card.BeatmapSet)); + + cancellationToken?.Cancel(); + + panelLoadTask = LoadComponentsAsync(newCards, cards => + { + foundContent.Clear(); + foundContent.AddRange(cards); + Loading.Hide(); + }, (cancellationToken = new CancellationTokenSource()).Token); } protected override void Dispose(bool isDisposing) @@ -327,7 +363,7 @@ namespace osu.Game.Overlays const int pagination_scroll_distance = 500; - bool shouldShowMore = panelLoadDelegate?.IsCompleted != false + bool shouldShowMore = panelLoadTask?.IsCompleted != false && Time.Current - lastFetchDisplayedTime > time_between_fetches && (ScrollFlow.ScrollableExtent > 0 && ScrollFlow.IsScrolledToEnd(pagination_scroll_distance)); diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs index a9723c9c62..25aed4c980 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -47,7 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private void onRulesetChanged(ValueChangedEvent ruleset) { @@ -57,8 +57,13 @@ namespace osu.Game.Overlays.BeatmapSet if (ruleset.NewValue == null) return; + var rulesetInstance = rulesets.GetRuleset(ruleset.NewValue.OnlineID)?.CreateInstance(); + + if (rulesetInstance == null) + return; + modsContainer.Add(new ModButton(new ModNoMod())); - modsContainer.AddRange(rulesets.GetRuleset(ruleset.NewValue.OnlineID).CreateInstance().AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m))); + modsContainer.AddRange(rulesetInstance.AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m))); modsContainer.ForEach(button => { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 018faf2011..2c78fa264e 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -128,6 +128,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (showPerformancePoints) columns.Add(new TableColumn(BeatmapsetsStrings.ShowScoreboardHeaderspp, Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 30))); + columns.Add(new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersTime, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize))); columns.Add(new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersMods, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize))); return columns.ToArray(); @@ -202,6 +203,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }); } + content.Add(new ScoreboardTime(score.Date, text_size) + { + Margin = new MarginPadding { Right = 10 } + }); + content.Add(new FillFlowContainer { Direction = FillDirection.Horizontal, diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs new file mode 100644 index 0000000000..ff1d3490b4 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs @@ -0,0 +1,56 @@ +// 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; +using osu.Game.Graphics; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.BeatmapSet.Scores +{ + public class ScoreboardTime : DrawableDate + { + public ScoreboardTime(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true) + : base(date, textSize, italic) + { + } + + protected override string Format() + { + var now = DateTime.Now; + var difference = now - Date; + + // web uses momentjs's custom locales to format the date for the purposes of the scoreboard. + // this is intended to be a best-effort, more legible approximation of that. + // compare: + // * https://github.com/ppy/osu-web/blob/a8f5a68fb435cb19a4faa4c7c4bce08c4f096933/resources/assets/lib/scoreboard-time.tsx + // * https://momentjs.com/docs/#/customization/ (reference for the customisation format) + + // TODO: support localisation (probably via `CommonStrings.CountHours()` etc.) + // requires pluralisable string support framework-side + + if (difference.TotalHours < 1) + return CommonStrings.TimeNow.ToString(); + if (difference.TotalDays < 1) + return "hr".ToQuantity((int)difference.TotalHours); + + // this is where this gets more complicated because of how the calendar works. + // since there's no `TotalMonths` / `TotalYears`, we have to iteratively add months/years + // and test against cutoff dates to determine how many months/years to show. + + if (Date > now.AddMonths(-1)) + return difference.TotalDays < 2 ? "1dy" : $"{(int)difference.TotalDays}dys"; + + for (int months = 1; months <= 11; ++months) + { + if (Date > now.AddMonths(-(months + 1))) + return months == 1 ? "1mo" : $"{months}mos"; + } + + int years = 1; + while (Date <= now.AddYears(-(years + 1))) + years += 1; + return years == 1 ? "1yr" : $"{years}yrs"; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 2fcdc9402d..a40f29abf2 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -7,6 +7,7 @@ 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; @@ -79,14 +80,16 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }; scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token) - .ContinueWith(ordered => Schedule(() => + .ContinueWith(task => Schedule(() => { if (loadCancellationSource.IsCancellationRequested) return; - var topScore = ordered.Result.First(); + var scores = task.GetResultSafely(); - scoreTable.DisplayScores(ordered.Result, apiBeatmap.Status.GrantsPerformancePoints()); + var topScore = scores.First(); + + scoreTable.DisplayScores(scores, apiBeatmap.Status.GrantsPerformancePoints()); scoreTable.Show(); var userScore = value.UserScore; @@ -94,7 +97,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores topScoresContainer.Add(new DrawableTopScore(topScore)); - if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) + if (userScoreInfo != null && userScoreInfo.OnlineID != topScore.OnlineID) topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); }), TaskContinuationOptions.OnlyOnRanToCompletion); }); diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index fa5a7c66d0..b9d3854066 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +11,6 @@ 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; using osuTK; using osuTK.Graphics; @@ -24,9 +22,6 @@ namespace osu.Game.Overlays public const float Y_PADDING = 25; public const float RIGHT_WIDTH = 275; - [Resolved] - private RulesetStore rulesets { get; set; } - private readonly Bindable beatmapSet = new Bindable(); // receive input outside our bounds so we can trigger a close event on ourselves. diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs index 2d071b7345..c65eefdee4 100644 --- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs @@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) + private void load() { foreach (var categoryEntries in Build.ChangelogEntries.GroupBy(b => b.Category).OrderBy(c => c.Key)) { diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index fe611d0134..ab97ae950d 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -35,7 +34,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load() { Header.Build.BindTarget = Current; diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index cc3ce63bf7..fde9d28b43 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -38,7 +38,7 @@ namespace osu.Game.Overlays public LocalisableString Title => ChatStrings.HeaderTitle; public LocalisableString Description => ChatStrings.HeaderDescription; - private const float textbox_height = 60; + private const float text_box_height = 60; private const float channel_selection_min_height = 0.3f; [Resolved] @@ -50,7 +50,7 @@ namespace osu.Game.Overlays private LoadingSpinner loading; - private FocusedTextBox textbox; + private FocusedTextBox textBox; private const int transition_length = 500; @@ -133,7 +133,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { - Bottom = textbox_height + Bottom = text_box_height }, }, new Container @@ -141,7 +141,7 @@ namespace osu.Game.Overlays Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - Height = textbox_height, + Height = text_box_height, Padding = new MarginPadding { Top = padding * 2, @@ -151,7 +151,7 @@ namespace osu.Game.Overlays }, Children = new Drawable[] { - textbox = new FocusedTextBox + textBox = new FocusedTextBox { RelativeSizeAxes = Axes.Both, Height = 1, @@ -197,7 +197,7 @@ namespace osu.Game.Overlays }, }; - textbox.OnCommit += postMessage; + textBox.OnCommit += postMessage; ChannelTabControl.Current.ValueChanged += current => channelManager.CurrentChannel.Value = current.NewValue; ChannelTabControl.ChannelSelectorActive.ValueChanged += active => ChannelSelectionOverlay.State.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden; @@ -208,12 +208,12 @@ namespace osu.Game.Overlays if (state.NewValue == Visibility.Visible) { - textbox.HoldFocus = false; + textBox.HoldFocus = false; if (1f - ChatHeight.Value < channel_selection_min_height) this.TransformBindableTo(ChatHeight, 1f - channel_selection_min_height, 800, Easing.OutQuint); } else - textbox.HoldFocus = true; + textBox.HoldFocus = true; }; ChannelSelectionOverlay.OnRequestJoin = channel => channelManager.JoinChannel(channel); @@ -237,10 +237,7 @@ namespace osu.Game.Overlays Schedule(() => { // TODO: consider scheduling bindable callbacks to not perform when overlay is not present. - channelManager.JoinedChannels.CollectionChanged += joinedChannelsChanged; - - foreach (Channel channel in channelManager.JoinedChannels) - ChannelTabControl.AddChannel(channel); + channelManager.JoinedChannels.BindCollectionChanged(joinedChannelsChanged, true); channelManager.AvailableChannels.CollectionChanged += availableChannelsChanged; availableChannelsChanged(null, null); @@ -256,7 +253,7 @@ namespace osu.Game.Overlays { if (e.NewValue == null) { - textbox.Current.Disabled = true; + textBox.Current.Disabled = true; currentChannelContainer.Clear(false); ChannelSelectionOverlay.Show(); return; @@ -265,7 +262,7 @@ namespace osu.Game.Overlays if (e.NewValue is ChannelSelectorTabItem.ChannelSelectorTabChannel) return; - textbox.Current.Disabled = e.NewValue.ReadOnly; + textBox.Current.Disabled = e.NewValue.ReadOnly; if (ChannelTabControl.Current.Value != e.NewValue) Scheduler.Add(() => ChannelTabControl.Current.Value = e.NewValue); @@ -405,7 +402,7 @@ namespace osu.Game.Overlays protected override void OnFocus(FocusEvent e) { // this is necessary as textbox is masked away and therefore can't get focus :( - textbox.TakeFocus(); + textBox.TakeFocus(); base.OnFocus(e); } @@ -414,7 +411,7 @@ namespace osu.Game.Overlays this.MoveToY(0, transition_length, Easing.OutQuint); this.FadeIn(transition_length, Easing.OutQuint); - textbox.HoldFocus = true; + textBox.HoldFocus = true; base.PopIn(); } @@ -426,7 +423,7 @@ namespace osu.Game.Overlays ChannelSelectionOverlay.Hide(); - textbox.HoldFocus = false; + textBox.HoldFocus = false; base.PopOut(); } @@ -436,12 +433,19 @@ namespace osu.Game.Overlays { case NotifyCollectionChangedAction.Add: foreach (Channel channel in args.NewItems.Cast()) - ChannelTabControl.AddChannel(channel); + { + if (channel.Type != ChannelType.Multiplayer) + ChannelTabControl.AddChannel(channel); + } + break; case NotifyCollectionChangedAction.Remove: foreach (Channel channel in args.OldItems.Cast()) { + if (!ChannelTabControl.Items.Contains(channel)) + continue; + ChannelTabControl.RemoveChannel(channel); var loaded = loadedChannels.Find(c => c.Channel == channel); @@ -477,9 +481,9 @@ namespace osu.Game.Overlays } } - private void postMessage(TextBox textbox, bool newText) + private void postMessage(TextBox textBox, bool newText) { - string text = textbox.Text.Trim(); + string text = textBox.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) return; @@ -489,7 +493,7 @@ namespace osu.Game.Overlays else channelManager.PostMessage(text); - textbox.Text = string.Empty; + textBox.Text = string.Empty; } private class TabsArea : Container diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 970fc5ccef..6a5734b553 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -317,7 +317,7 @@ namespace osu.Game.Overlays.Comments private class NoCommentsPlaceholder : CompositeDrawable { [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load() { Height = 80; RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 43f4177bd0..3286b6c5c0 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -362,15 +362,15 @@ namespace osu.Game.Overlays.Comments private void updateButtonsState() { - int loadedReplesCount = loadedReplies.Count; - bool hasUnloadedReplies = loadedReplesCount != Comment.RepliesCount; + int loadedRepliesCount = loadedReplies.Count; + bool hasUnloadedReplies = loadedRepliesCount != Comment.RepliesCount; - loadRepliesButton.FadeTo(hasUnloadedReplies && loadedReplesCount == 0 ? 1 : 0); - showMoreButton.FadeTo(hasUnloadedReplies && loadedReplesCount > 0 ? 1 : 0); - showRepliesButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + loadRepliesButton.FadeTo(hasUnloadedReplies && loadedRepliesCount == 0 ? 1 : 0); + showMoreButton.FadeTo(hasUnloadedReplies && loadedRepliesCount > 0 ? 1 : 0); + showRepliesButton.FadeTo(loadedRepliesCount != 0 ? 1 : 0); if (Comment.IsTopLevel) - chevronButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + chevronButton.FadeTo(loadedRepliesCount != 0 ? 1 : 0); showMoreButton.IsLoading = loadRepliesButton.IsLoading = false; } diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs index 269ed81bb5..fde20575fc 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -5,6 +5,7 @@ using System.Collections.Specialized; 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.Screens; @@ -43,9 +44,6 @@ namespace osu.Game.Overlays.Dashboard }; } - [Resolved] - private IAPIProvider api { get; set; } - [Resolved] private UserLookupCache users { get; set; } @@ -64,17 +62,19 @@ namespace osu.Game.Overlays.Dashboard case NotifyCollectionChangedAction.Add: foreach (int id in e.NewItems.OfType().ToArray()) { - users.GetUserAsync(id).ContinueWith(u => + users.GetUserAsync(id).ContinueWith(task => { - if (u.Result == null) return; + var user = task.GetResultSafely(); + + if (user == null) return; Schedule(() => { // user may no longer be playing. - if (!playingUsers.Contains(u.Result.Id)) + if (!playingUsers.Contains(user.Id)) return; - userFlow.Add(createUserPanel(u.Result)); + userFlow.Add(createUserPanel(user)); }); }); } diff --git a/osu.Game/Overlays/Settings/Sidebar.cs b/osu.Game/Overlays/ExpandingButtonContainer.cs similarity index 60% rename from osu.Game/Overlays/Settings/Sidebar.cs rename to osu.Game/Overlays/ExpandingButtonContainer.cs index 93b1b19b17..4eb8c47a1f 100644 --- a/osu.Game/Overlays/Settings/Sidebar.cs +++ b/osu.Game/Overlays/ExpandingButtonContainer.cs @@ -1,47 +1,46 @@ -// 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 System; using System.Linq; using osu.Framework; -using osu.Framework.Allocation; 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.Containers; +using osu.Game.Graphics.UserInterface; using osuTK; -namespace osu.Game.Overlays.Settings +namespace osu.Game.Overlays { - public class Sidebar : Container, IStateful + public abstract class ExpandingButtonContainer : Container, IStateful { - private readonly Box background; - private readonly FillFlowContainer content; - public const float DEFAULT_WIDTH = 70; - public const int EXPANDED_WIDTH = 200; + private readonly float contractedWidth; + private readonly float expandedWidth; public event Action StateChanged; - protected override Container Content => content; + protected override Container Content => FillFlow; - public Sidebar() + protected FillFlowContainer FillFlow { get; } + + protected ExpandingButtonContainer(float contractedWidth, float expandedWidth) { + this.contractedWidth = contractedWidth; + this.expandedWidth = expandedWidth; + RelativeSizeAxes = Axes.Y; + Width = contractedWidth; + InternalChildren = new Drawable[] { - background = new Box - { - Colour = OsuColour.Gray(0.02f), - RelativeSizeAxes = Axes.Both, - }, new SidebarScrollContainer { Children = new[] { - content = new FillFlowContainer + FillFlow = new FillFlowContainer { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, @@ -54,12 +53,6 @@ namespace osu.Game.Overlays.Settings }; } - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - background.Colour = colourProvider.Background5; - } - private ScheduledDelegate expandEvent; private ExpandedState state; @@ -72,7 +65,7 @@ namespace osu.Game.Overlays.Settings protected override void OnHoverLost(HoverLostEvent e) { expandEvent?.Cancel(); - lastHoveredButton = null; + hoveredButton = null; State = ExpandedState.Contracted; base.OnHoverLost(e); @@ -107,11 +100,11 @@ namespace osu.Game.Overlays.Settings switch (state) { default: - this.ResizeTo(new Vector2(DEFAULT_WIDTH, Height), 500, Easing.OutQuint); + this.ResizeTo(new Vector2(contractedWidth, Height), 500, Easing.OutQuint); break; case ExpandedState.Expanded: - this.ResizeTo(new Vector2(EXPANDED_WIDTH, Height), 500, Easing.OutQuint); + this.ResizeTo(new Vector2(expandedWidth, Height), 500, Easing.OutQuint); break; } @@ -119,24 +112,24 @@ namespace osu.Game.Overlays.Settings } } - private Drawable lastHoveredButton; - - private Drawable hoveredButton => content.Children.FirstOrDefault(c => c.IsHovered); + private Drawable hoveredButton; private void queueExpandIfHovering() { - // only expand when we hover a different button. - if (lastHoveredButton == hoveredButton) return; + // if the same button is hovered, let the scheduled expand play out.. + if (hoveredButton?.IsHovered == true) + return; - if (!IsHovered) return; + // ..otherwise check whether a new button is hovered, and if so, queue a new hover operation. - if (State != ExpandedState.Expanded) - { - expandEvent?.Cancel(); + // usually we wouldn't use ChildrenOfType in implementations, but this is the simplest way + // to handle cases like the editor where the buttons may be nested within a child hierarchy. + hoveredButton = FillFlow.ChildrenOfType().FirstOrDefault(c => c.IsHovered); + + expandEvent?.Cancel(); + + if (hoveredButton?.IsHovered == true && State != ExpandedState.Expanded) expandEvent = Scheduler.AddDelayed(() => State = ExpandedState.Expanded, 750); - } - - lastHoveredButton = hoveredButton; } } diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 76358fca3f..3346c6d97d 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -342,11 +342,9 @@ namespace osu.Game.Overlays private void changeTrack() { + var queuedTrack = getQueuedTrack(); + var lastTrack = CurrentTrack; - - var queuedTrack = new DrawableTrack(current.LoadTrack()); - queuedTrack.Completed += () => onTrackCompleted(current); - CurrentTrack = queuedTrack; // At this point we may potentially be in an async context from tests. This is extremely dangerous but we have to make do for now. @@ -370,6 +368,15 @@ namespace osu.Game.Overlays }); } + private DrawableTrack getQueuedTrack() + { + // Important to keep this in its own method to avoid inadvertently capturing unnecessary variables in the callback. + // Can lead to leaks. + var queuedTrack = new DrawableTrack(current.LoadTrack()); + queuedTrack.Completed += () => onTrackCompleted(current); + return queuedTrack; + } + private void onTrackCompleted(WorkingBeatmap workingBeatmap) { // the source of track completion is the audio thread, so the beatmap may have changed before firing. diff --git a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs index feffb4fa66..754f9bd600 100644 --- a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Notifications [BackgroundDependencyLoader] private void load(OsuColour colours) { - IconBackgound.Colour = ColourInfo.GradientVertical(colours.GreenDark, colours.GreenLight); + IconBackground.Colour = ColourInfo.GradientVertical(colours.GreenDark, colours.GreenLight); } } } diff --git a/osu.Game/Overlays/Notifications/SimpleNotification.cs b/osu.Game/Overlays/Notifications/SimpleNotification.cs index 17ec12a4ca..c32e40ffc8 100644 --- a/osu.Game/Overlays/Notifications/SimpleNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleNotification.cs @@ -43,13 +43,13 @@ namespace osu.Game.Overlays.Notifications private readonly TextFlowContainer textDrawable; private readonly SpriteIcon iconDrawable; - protected Box IconBackgound; + protected Box IconBackground; public SimpleNotification() { IconContent.AddRange(new Drawable[] { - IconBackgound = new Box + IconBackground = new Box { RelativeSizeAxes = Axes.Both, Colour = ColourInfo.GradientVertical(OsuColour.Gray(0.2f), OsuColour.Gray(0.6f)) diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 51214fe460..9939ba024e 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -5,12 +5,14 @@ using System; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Configuration.Tracking; 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.Game.Configuration; using osu.Game.Graphics; using osuTK; using osuTK.Graphics; @@ -28,6 +30,8 @@ namespace osu.Game.Overlays.OSD private Sample sampleOff; private Sample sampleChange; + private Bindable lastPlaybackTime; + public TrackedSettingToast(SettingDescription description) : base(description.Name, description.Value, description.Shortcut) { @@ -75,10 +79,28 @@ namespace osu.Game.Overlays.OSD optionLights.Add(new OptionLight { Glowing = i == selectedOption }); } + [Resolved] + private SessionStatics statics { get; set; } + protected override void LoadComplete() { base.LoadComplete(); + playSound(); + } + + private void playSound() + { + // This debounce code roughly follows what we're using in HoverSampleDebounceComponent. + // We're sharing the existing static for hover sounds because it doesn't really matter if they block each other. + // This is a simple solution, but if this ever becomes a problem (or other performance issues arise), + // the whole toast system should be rewritten to avoid recreating this drawable each time a value changes. + lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); + + bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + + if (!enoughTimePassedSinceLastPlayback) return; + if (optionCount == 1) { if (selectedOption == 0) @@ -93,6 +115,8 @@ namespace osu.Game.Overlays.OSD sampleChange.Frequency.Value = 1 + (double)selectedOption / (optionCount - 1) * 0.25f; sampleChange.Play(); } + + lastPlaybackTime.Value = Time.Current; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/OnScreenDisplay.cs b/osu.Game/Overlays/OnScreenDisplay.cs index be9d3cd794..6b3696ced9 100644 --- a/osu.Game/Overlays/OnScreenDisplay.cs +++ b/osu.Game/Overlays/OnScreenDisplay.cs @@ -101,7 +101,7 @@ namespace osu.Game.Overlays DisplayTemporarily(box); }); - private void displayTrackedSettingChange(SettingDescription description) => Display(new TrackedSettingToast(description)); + private void displayTrackedSettingChange(SettingDescription description) => Scheduler.AddOnce(Display, new TrackedSettingToast(description)); private TransformSequence fadeIn; private ScheduledDelegate fadeOut; diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index a13f5ed6ce..00a866f1f4 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -7,7 +7,6 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile.Header.Components; @@ -30,7 +29,7 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, TextureStore textures) + private void load(OverlayColourProvider colourProvider) { Container hiddenDetailContainer; Container expandedDetailContainer; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs index 229bbaef83..49744e885a 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs @@ -98,7 +98,7 @@ namespace osu.Game.Overlays.Profile.Header.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - background.Colour = colours.GreySeafoamDarker; + background.Colour = colours.GreySeaFoamDarker; } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index d6e515d8a1..d195babcbf 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -42,7 +42,9 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateStatistics(UserStatistics statistics) { - int[] userRanks = statistics?.RankHistory?.Data; + // checking both IsRanked and RankHistory is required. + // see https://github.com/ppy/osu-web/blob/154ceafba0f35a1dd935df53ec98ae2ea5615f9f/resources/assets/lib/profile-page/rank-chart.tsx#L46 + int[] userRanks = statistics?.IsRanked == true ? statistics.RankHistory?.Data : null; Data = userRanks?.Select((x, index) => new KeyValuePair(index, x)).Where(x => x.Value != 0).ToArray(); } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index 6223b32814..fc6fce0d8e 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; @@ -22,7 +23,7 @@ namespace osu.Game.Overlays.Profile public abstract string Identifier { get; } - private readonly FillFlowContainer content; + private readonly FillFlowContainer content; private readonly Box background; private readonly Box underscore; @@ -79,7 +80,9 @@ namespace osu.Game.Overlays.Profile } } }, - content = new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the content (last item should be front-most). + // particularly important in BeatmapsSection, as it uses beatmap cards, which have expandable overhanging content. + content = new ReverseChildIDFillFlowContainer { Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index e46e503dfa..d39074bd49 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -61,7 +61,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps new GetUserBeatmapsRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); protected override Drawable CreateDrawableItem(APIBeatmapSet model) => model.OnlineID > 0 - ? new BeatmapCard(model) + ? new BeatmapCardNormal(model) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index c943d129cc..ad1192a13a 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -28,7 +28,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical ItemsContainer.Direction = FillDirection.Vertical; } - protected override int GetCount(APIUser user) => user.BeatmapPlaycountsCount; + protected override int GetCount(APIUser user) => user.BeatmapPlayCountsCount; protected override APIRequest> CreateRequest() => new GetUserMostPlayedBeatmapsRequest(User.Value.Id, VisiblePages++, ItemsPerPage); diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs index bca88318d5..51d704a6b0 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs @@ -17,6 +17,6 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { } - protected override APIUserHistoryCount[] GetValues(APIUser user) => user?.MonthlyPlaycounts; + protected override APIUserHistoryCount[] GetValues(APIUser user) => user?.MonthlyPlayCounts; } } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs index d0cfe9fa54..ce05beffd1 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu [BackgroundDependencyLoader] private void load() { - date.Colour = colours.GreySeafoamLighter; + date.Colour = colours.GreySeaFoamLighter; var formattedSource = MessageFormatter.FormatText(getString(historyItem)); linkFlowContainer.AddLinks(formattedSource.Text, formattedSource.Links); } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index affe9ecb0c..9dcbf6142d 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -1,21 +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 osuTK; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Online.API; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics; using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; +using osuTK; namespace osu.Game.Overlays.Profile.Sections { @@ -24,13 +24,10 @@ namespace osu.Game.Overlays.Profile.Sections [Resolved] private IAPIProvider api { get; set; } - [Resolved] - protected RulesetStore Rulesets { get; private set; } - protected int VisiblePages; protected int ItemsPerPage; - protected FillFlowContainer ItemsContainer { get; private set; } + protected ReverseChildIDFillFlowContainer ItemsContainer { get; private set; } private APIRequest> retrievalRequest; private CancellationTokenSource loadCancellation; @@ -52,11 +49,15 @@ namespace osu.Game.Overlays.Profile.Sections Direction = FillDirection.Vertical, Children = new Drawable[] { - ItemsContainer = new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the items (last item should be front-most). + // particularly important in PaginatedBeatmapContainer, as it uses beatmap cards, which have expandable overhanging content. + ItemsContainer = new ReverseChildIDFillFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Spacing = new Vector2(0, 2), + // ensure the container and its contents are in front of the "more" button. + Depth = float.MinValue }, moreButton = new ShowMoreButton { diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index fb464e1b41..562be0403e 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -28,7 +28,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private const float performance_background_shear = 0.45f; - protected readonly APIScoreInfo Score; + protected readonly APIScore Score; [Resolved] private OsuColour colours { get; set; } @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks [Resolved] private OverlayColourProvider colourProvider { get; set; } - public DrawableProfileScore(APIScoreInfo score) + public DrawableProfileScore(APIScore score) { Score = score; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index e653be5cfa..78ae0a5634 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly double weight; - public DrawableProfileWeightedScore(APIScoreInfo score, double weight) + public DrawableProfileWeightedScore(APIScore score, double weight) : base(score) { this.weight = weight; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index c3f10587a9..5532e35cc5 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -15,7 +15,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; @@ -51,7 +51,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks } } - protected override void OnItemsReceived(List items) + protected override void OnItemsReceived(List items) { if (VisiblePages == 0) drawableItemIndex = 0; @@ -59,12 +59,12 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks base.OnItemsReceived(items); } - protected override APIRequest> CreateRequest() => + protected override APIRequest> CreateRequest() => new GetUserScoresRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); private int drawableItemIndex; - protected override Drawable CreateDrawableItem(APIScoreInfo model) + protected override Drawable CreateDrawableItem(APIScore model) { switch (type) { diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index cb8dae0bbc..7a27c6e4e1 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private readonly APIRecentActivity activity; diff --git a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs index cc553ad361..7f5d096fe2 100644 --- a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs +++ b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs @@ -1,21 +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 osu.Framework.Graphics; -using osu.Framework.Bindables; -using osu.Game.Rulesets; -using osu.Framework.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; -using osuTK; -using osu.Framework.Allocation; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.Rankings.Tables; using System.Linq; using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Graphics.Containers; 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.Rankings.Tables; +using osu.Game.Rulesets; +using osuTK; namespace osu.Game.Overlays.Rankings { @@ -29,9 +29,6 @@ namespace osu.Game.Overlays.Rankings [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - private CancellationTokenSource cancellationToken; private GetSpotlightRankingsRequest getRankingsRequest; private GetSpotlightsRequest spotlightsRequest; @@ -138,12 +135,14 @@ namespace osu.Game.Overlays.Rankings Children = new Drawable[] { new ScoresTable(1, response.Users), - new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most). + new ReverseChildIDFillFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Bottom = ExpandedContentScrollContainer.HEIGHT }, Spacing = new Vector2(10), - Children = response.BeatmapSets.Select(b => new BeatmapCard(b) + Children = response.BeatmapSets.Select(b => new BeatmapCardNormal(b) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index 6e6230f958..fd69b6c80a 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -54,13 +54,15 @@ namespace osu.Game.Overlays.Rankings.Tables Spacing = new Vector2(0, row_spacing), }); - rankings.ForEach(_ => backgroundFlow.Add(new TableRowBackground { Height = row_height })); + rankings.ForEach(s => backgroundFlow.Add(CreateRowBackground(s))); Columns = mainHeaders.Concat(CreateAdditionalHeaders()).Cast().ToArray(); - Content = rankings.Select((s, i) => createContent((page - 1) * items_per_page + i, s)).ToArray().ToRectangular(); + Content = rankings.Select((s, i) => CreateRowContent((page - 1) * items_per_page + i, s)).ToArray().ToRectangular(); } - private Drawable[] createContent(int index, TModel item) => new Drawable[] { createIndexDrawable(index), createMainContent(item) }.Concat(CreateAdditionalContent(item)).ToArray(); + protected virtual Drawable CreateRowBackground(TModel item) => new TableRowBackground { Height = row_height }; + + protected virtual Drawable[] CreateRowContent(int index, TModel item) => new Drawable[] { createIndexDrawable(index), createMainContent(item) }.Concat(CreateAdditionalContent(item)).ToArray(); private static RankingsTableColumn[] mainHeaders => new[] { diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index cc2ef55a2b..5d150c9535 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -24,6 +24,31 @@ namespace osu.Game.Overlays.Rankings.Tables protected virtual IEnumerable GradeColumns => new List { RankingsStrings.Statss, RankingsStrings.Stats, RankingsStrings.Stata }; + protected override Drawable CreateRowBackground(UserStatistics item) + { + var background = base.CreateRowBackground(item); + + // see: https://github.com/ppy/osu-web/blob/9de00a0b874c56893d98261d558d78d76259d81b/resources/views/multiplayer/rooms/_rankings_table.blade.php#L23 + if (!item.User.Active) + background.Alpha = 0.5f; + + return background; + } + + protected override Drawable[] CreateRowContent(int index, UserStatistics item) + { + var content = base.CreateRowContent(index, item); + + // see: https://github.com/ppy/osu-web/blob/9de00a0b874c56893d98261d558d78d76259d81b/resources/views/multiplayer/rooms/_rankings_table.blade.php#L23 + if (!item.User.Active) + { + foreach (var d in content) + d.Alpha = 0.5f; + } + + return content; + } + protected override RankingsTableColumn[] CreateAdditionalHeaders() => new[] { new RankingsTableColumn(RankingsStrings.StatAccuracy, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), diff --git a/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs b/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs index 93b07fbac7..3945a410ab 100644 --- a/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays.Settings { dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - Config = dependencies.Get().GetConfigFor(ruleset); + Config = dependencies.Get().GetConfigFor(ruleset); if (Config != null) dependencies.Cache(Config); diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index 6f48768dcd..8d4fc5fc9f 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -2,10 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Database; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.DebugSettings @@ -15,7 +15,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings protected override LocalisableString Header => DebugSettingsStrings.MemoryHeader; [BackgroundDependencyLoader] - private void load(FrameworkDebugConfigManager config, GameHost host) + private void load(GameHost host, RealmContextFactory realmFactory) { Children = new Drawable[] { @@ -24,6 +24,17 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings Text = DebugSettingsStrings.ClearAllCaches, Action = host.Collect }, + new SettingsButton + { + Text = DebugSettingsStrings.CompactRealm, + Action = () => + { + // Blocking operations implicitly causes a Compact(). + using (realmFactory.BlockAllOperations()) + { + } + } + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs index dba64d695a..5029c6a617 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs @@ -14,20 +14,23 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay protected override LocalisableString Header => GameplaySettingsStrings.AudioHeader; [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, OsuConfigManager osuConfig) { Children = new Drawable[] { - new SettingsCheckbox + new SettingsSlider { - LabelText = GameplaySettingsStrings.PositionalHitsounds, - Current = config.GetBindable(OsuSetting.PositionalHitSounds) + LabelText = AudioSettingsStrings.PositionalLevel, + Keywords = new[] { @"positional", @"balance" }, + Current = osuConfig.GetBindable(OsuSetting.PositionalHitsoundsLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsCheckbox { LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) - }, + } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 6bcb5ef715..158d8811b5 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Platform; @@ -45,9 +46,9 @@ namespace osu.Game.Overlays.Settings.Sections.General Action = () => { checkForUpdatesButton.Enabled.Value = false; - Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(t => Schedule(() => + Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(task => Schedule(() => { - if (!t.Result) + if (!task.GetResultSafely()) { notifications?.Post(new SimpleNotification { diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 0334167759..4235dc0a05 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -67,7 +67,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, new SettingsCheckbox { - LabelText = MouseSettingsStrings.DisableMouseWheel, + LabelText = MouseSettingsStrings.DisableMouseWheelVolumeAdjust, + TooltipText = MouseSettingsStrings.DisableMouseWheelVolumeAdjustTooltip, Current = osuConfig.GetBindable(OsuSetting.MouseDisableWheel) }, new SettingsCheckbox diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index c94b418331..802d442ced 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -107,8 +107,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input t.NewLine(); t.AddText("If your tablet is not detected, please read "); t.AddLink("this FAQ", LinkAction.External, RuntimeInfo.OS == RuntimeInfo.Platform.Windows - ? @"https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Windows-FAQ" - : @"https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ"); + ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" + : @"https://opentabletdriver.net/Wiki/FAQ/Linux"); t.AddText(" for troubleshooting steps."); } }), diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs index 0814e3c824..98bc8d88be 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -63,7 +63,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDark + Colour = colours.GreySeaFoamDark }, new GridContainer { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index acdf9cdea6..98ccbf85fd 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -106,7 +106,10 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => { deleteSkinsButton.Enabled.Value = false; - Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); + Task.Run(() => + { + skins.Delete(); + }).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); })); } }); diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 0eb65b4b0f..0fa6d78d4b 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -32,32 +32,26 @@ namespace osu.Game.Overlays.Settings.Sections Icon = FontAwesome.Solid.PaintBrush }; - private readonly Bindable dropdownBindable = new Bindable { Default = SkinInfo.Default }; - private readonly Bindable configBindable = new Bindable(); + private readonly Bindable> dropdownBindable = new Bindable> { Default = DefaultSkin.CreateInfo().ToLiveUnmanaged() }; + private readonly Bindable configBindable = new Bindable(); - private static readonly SkinInfo random_skin_info = new SkinInfo + private static readonly ILive random_skin_info = new SkinInfo { ID = SkinInfo.RANDOM_SKIN, Name = "", - }; + }.ToLiveUnmanaged(); - private List skinItems; - - private int firstNonDefaultSkinIndex - { - get - { - int index = skinItems.FindIndex(s => s.ID > 0); - if (index < 0) - index = skinItems.Count; - - return index; - } - } + private List> skinItems; [Resolved] private SkinManager skins { get; set; } + [Resolved] + private RealmContextFactory realmFactory { get; set; } + + private IDisposable realmSubscription; + private IQueryable realmSkins; + [BackgroundDependencyLoader(permitNulls: true)] private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) { @@ -75,96 +69,95 @@ namespace osu.Game.Overlays.Settings.Sections new ExportSkinButton(), }; - skins.ItemUpdated += itemUpdated; - skins.ItemRemoved += itemRemoved; - config.BindWith(OsuSetting.Skin, configBindable); + } + + protected override void LoadComplete() + { + base.LoadComplete(); skinDropdown.Current = dropdownBindable; + + realmSkins = realmFactory.Context.All() + .Where(s => !s.DeletePending) + .OrderByDescending(s => s.Protected) // protected skins should be at the top. + .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase); + + realmSubscription = realmSkins + .QueryAsyncWithNotifications((sender, changes, error) => + { + if (changes == null) + return; + + // Eventually this should be handling the individual changes rather than refreshing the whole dropdown. + updateItems(); + }); + updateItems(); - // Todo: This should not be necessary when OsuConfigManager is databased - if (skinDropdown.Items.All(s => s.ID != configBindable.Value)) - configBindable.Value = 0; + configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig)); + updateSelectedSkinFromConfig(); - configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true); dropdownBindable.BindValueChanged(skin => { - if (skin.NewValue == random_skin_info) + if (skin.NewValue.Equals(random_skin_info)) { + var skinBefore = skins.CurrentSkinInfo.Value; + skins.SelectRandomSkin(); + + if (skinBefore == skins.CurrentSkinInfo.Value) + { + // the random selection didn't change the skin, so we should manually update the dropdown to match. + dropdownBindable.Value = skins.CurrentSkinInfo.Value; + } + return; } - configBindable.Value = skin.NewValue.ID; + configBindable.Value = skin.NewValue.ID.ToString(); }); } private void updateSelectedSkinFromConfig() { - int id = configBindable.Value; + ILive skin = null; - var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id); + if (Guid.TryParse(configBindable.Value, out var configId)) + skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId); - if (skin == null) - { - // there may be a thread race condition where an item is selected that hasn't yet been added to the dropdown. - // to avoid adding complexity, let's just ensure the item is added so we can perform the selection. - skin = skins.Query(s => s.ID == id); - addItem(skin); - } - - dropdownBindable.Value = skin; + dropdownBindable.Value = skin ?? skinDropdown.Items.First(); } private void updateItems() { - skinItems = skins.GetAllUsableSkins(); - skinItems.Insert(firstNonDefaultSkinIndex, random_skin_info); - sortUserSkins(skinItems); + int protectedCount = realmSkins.Count(s => s.Protected); + + skinItems = realmSkins.ToLive(realmFactory); + + skinItems.Insert(protectedCount, random_skin_info); + skinDropdown.Items = skinItems; } - private void itemUpdated(SkinInfo item) => Schedule(() => addItem(item)); - - private void addItem(SkinInfo item) - { - List newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList(); - sortUserSkins(newDropdownItems); - skinDropdown.Items = newDropdownItems; - } - - private void itemRemoved(SkinInfo item) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).ToArray()); - - private void sortUserSkins(List skinsList) - { - // Sort user skins separately from built-in skins - skinsList.Sort(firstNonDefaultSkinIndex, skinsList.Count - firstNonDefaultSkinIndex, - Comparer.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase))); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (skins != null) - { - skins.ItemUpdated -= itemUpdated; - skins.ItemRemoved -= itemRemoved; - } + realmSubscription?.Dispose(); } - private class SkinSettingsDropdown : SettingsDropdown + private class SkinSettingsDropdown : SettingsDropdown> { - protected override OsuDropdown CreateDropdown() => new SkinDropdownControl(); + protected override OsuDropdown> CreateDropdown() => new SkinDropdownControl(); private class SkinDropdownControl : DropdownControl { - protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString(); + protected override LocalisableString GenerateItemText(ILive item) => item.ToString(); } } - private class ExportSkinButton : SettingsButton + public class ExportSkinButton : SettingsButton { [Resolved] private SkinManager skins { get; set; } @@ -179,16 +172,21 @@ namespace osu.Game.Overlays.Settings.Sections { Text = SkinSettingsStrings.ExportSkinButton; Action = export; + } + + protected override void LoadComplete() + { + base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); } private void export() { try { - new LegacySkinExporter(storage).Export(currentSkin.Value.SkinInfo); + currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s)); } catch (Exception e) { diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index ed49ce2b63..263f2f4829 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Settings public class SettingsFooter : FillFlowContainer { [BackgroundDependencyLoader] - private void load(OsuGameBase game, OsuColour colours, RulesetStore rulesets) + private void load(OsuGameBase game, RulesetStore rulesets) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index cbe9f7fc64..cc4446033a 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -67,7 +68,7 @@ namespace osu.Game.Overlays.Settings private class OutlinedNumberBox : OutlinedTextBox { - protected override bool CanAddCharacter(char character) => char.IsNumber(character); + protected override bool CanAddCharacter(char character) => character.IsAsciiDigit(); public new void NotifyInputError() => base.NotifyInputError(); } diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index 0ae353602e..2539c32806 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -65,7 +65,7 @@ namespace osu.Game.Overlays.Settings } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuColour colours) + private void load(OverlayColourProvider colourProvider) { AddRangeInternal(new Drawable[] { diff --git a/osu.Game/Overlays/Settings/SettingsSidebar.cs b/osu.Game/Overlays/Settings/SettingsSidebar.cs new file mode 100644 index 0000000000..e6ce90c33e --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsSidebar.cs @@ -0,0 +1,31 @@ +// 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.Shapes; + +namespace osu.Game.Overlays.Settings +{ + public class SettingsSidebar : ExpandingButtonContainer + { + public const float DEFAULT_WIDTH = 70; + public const int EXPANDED_WIDTH = 200; + + public SettingsSidebar() + : base(DEFAULT_WIDTH, EXPANDED_WIDTH) + { + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AddInternal(new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/SidebarIconButton.cs b/osu.Game/Overlays/Settings/SidebarIconButton.cs index fd57996b1b..6f3d3d5d52 100644 --- a/osu.Game/Overlays/Settings/SidebarIconButton.cs +++ b/osu.Game/Overlays/Settings/SidebarIconButton.cs @@ -62,14 +62,14 @@ namespace osu.Game.Overlays.Settings { textIconContent = new Container { - Width = Sidebar.DEFAULT_WIDTH, + Width = SettingsSidebar.DEFAULT_WIDTH, RelativeSizeAxes = Axes.Y, Colour = OsuColour.Gray(0.6f), Children = new Drawable[] { headerText = new OsuSpriteText { - Position = new Vector2(Sidebar.DEFAULT_WIDTH + 10, 0), + Position = new Vector2(SettingsSidebar.DEFAULT_WIDTH + 10, 0), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 0ceb7fc50d..ba7118cffe 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -27,7 +27,7 @@ namespace osu.Game.Overlays public const float TRANSITION_LENGTH = 600; - private const float sidebar_width = Sidebar.DEFAULT_WIDTH; + private const float sidebar_width = SettingsSidebar.DEFAULT_WIDTH; /// /// The width of the settings panel content, excluding the sidebar. @@ -43,7 +43,7 @@ namespace osu.Game.Overlays protected override Container Content => ContentContainer; - protected Sidebar Sidebar; + protected SettingsSidebar Sidebar; private SidebarIconButton selectedSidebarButton; public SettingsSectionsContainer SectionsContainer { get; private set; } @@ -129,7 +129,7 @@ namespace osu.Game.Overlays if (showSidebar) { - AddInternal(Sidebar = new Sidebar { Width = sidebar_width }); + AddInternal(Sidebar = new SettingsSidebar { Width = sidebar_width }); } CreateSections()?.ForEach(AddSection); @@ -244,7 +244,7 @@ namespace osu.Game.Overlays if (selectedSidebarButton != null) selectedSidebarButton.Selected = false; - selectedSidebarButton = Sidebar.Children.FirstOrDefault(b => b.Section == section.NewValue); + selectedSidebarButton = Sidebar.Children.OfType().FirstOrDefault(b => b.Section == section.NewValue); if (selectedSidebarButton != null) selectedSidebarButton.Selected = true; diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index a65d792a9f..da806c09d3 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load() { - Size = new Vector2(Sidebar.DEFAULT_WIDTH); + Size = new Vector2(SettingsSidebar.DEFAULT_WIDTH); AddRange(new Drawable[] { diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs new file mode 100644 index 0000000000..ca0980a9c9 --- /dev/null +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -0,0 +1,190 @@ +// 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.Caching; +using osu.Framework.Extensions.EnumExtensions; +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.Framework.Layout; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public abstract class SettingsToolboxGroup : Container + { + private const float transition_duration = 250; + private const int container_width = 270; + 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; + private readonly IconButton button; + + private bool expanded = true; + + public bool Expanded + { + get => expanded; + set + { + if (expanded == value) return; + + expanded = value; + + content.ClearTransforms(); + + if (expanded) + content.AutoSizeAxes = Axes.Y; + else + { + content.AutoSizeAxes = Axes.None; + content.ResizeHeightTo(0, transition_duration, Easing.OutQuint); + } + + updateExpanded(); + } + } + + private Color4 expandedColour; + + private readonly OsuSpriteText headerText; + + /// + /// Create a new instance. + /// + /// The title to be displayed in the header of this group. + protected SettingsToolboxGroup(string title) + { + AutoSizeAxes = Axes.Y; + Width = container_width; + Masking = true; + CornerRadius = corner_radius; + BorderColour = Color4.Black; + BorderThickness = border_thickness; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f, + }, + new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Container + { + Name = @"Header", + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = header_height, + Children = new Drawable[] + { + headerText = new OsuSpriteText + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Text = title.ToUpperInvariant(), + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), + Padding = new MarginPadding { Left = 10, Right = 30 }, + }, + button = new IconButton + { + Origin = Anchor.Centre, + Anchor = Anchor.CentreRight, + Position = new Vector2(-15, 0), + Icon = FontAwesome.Solid.Bars, + Scale = new Vector2(0.75f), + Action = () => Expanded = !Expanded, + }, + } + }, + 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), + } + } + }, + }; + } + + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + if (invalidation.HasFlagFast(Invalidation.DrawSize)) + headerTextVisibilityCache.Invalidate(); + + return base.OnInvalidate(invalidation, source); + } + + protected override void Update() + { + base.Update(); + + if (!headerTextVisibilityCache.IsValid) + // These toolbox grouped may be contracted to only show icons. + // For now, let's hide the header to avoid text truncation weirdness in such cases. + headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.Delay(600).FadeTo(inactive_alpha, fade_duration, Easing.OutQuint); + updateExpanded(); + } + + protected override bool OnHover(HoverEvent e) + { + this.FadeIn(fade_duration, Easing.OutQuint); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + this.FadeTo(inactive_alpha, fade_duration, Easing.OutQuint); + base.OnHoverLost(e); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + expandedColour = colours.Yellow; + } + + private void updateExpanded() => button.FadeColour(expanded ? expandedColour : Color4.White, 200, Easing.InOutQuint); + + protected override Container Content => content; + + protected override bool OnMouseDown(MouseDownEvent e) => true; + } +} diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs index 6f0b433acb..789ed457a4 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -5,19 +5,14 @@ using System.Linq; using Markdig.Extensions.Yaml; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers.Markdown; using osu.Game.Graphics.Containers.Markdown; -using osu.Game.Online.API; namespace osu.Game.Overlays.Wiki.Markdown { public class WikiMarkdownContainer : OsuMarkdownContainer { - [Resolved] - private IAPIProvider api { get; set; } - public string CurrentPath { set => DocumentUrl = value; diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 47736ee033..991b567f57 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_OVERALL_DIFFICULTY = 5; protected const int ATTRIB_ID_APPROACH_RATE = 7; protected const int ATTRIB_ID_MAX_COMBO = 9; - protected const int ATTRIB_ID_STRAIN = 11; + protected const int ATTRIB_ID_DIFFICULTY = 11; protected const int ATTRIB_ID_GREAT_HIT_WINDOW = 13; protected const int ATTRIB_ID_SCORE_MULTIPLIER = 15; protected const int ATTRIB_ID_FLASHLIGHT = 17; diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 01b4150030..6b61dd3efb 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -120,14 +120,14 @@ namespace osu.Game.Rulesets.Difficulty /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. /// /// A collection of structures describing the difficulty of the beatmap for each mod combination. - public IEnumerable CalculateAll() + public IEnumerable CalculateAll(CancellationToken cancellationToken = default) { foreach (var combination in CreateDifficultyAdjustmentModCombinations()) { if (combination is MultiMod multi) - yield return Calculate(multi.Mods); + yield return Calculate(multi.Mods, cancellationToken); else - yield return Calculate(combination.Yield()); + yield return Calculate(combination.Yield(), cancellationToken); } } @@ -145,7 +145,11 @@ namespace osu.Game.Rulesets.Difficulty { playableMods = mods.Select(m => m.DeepClone()).ToArray(); - Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); + // Only pass through the cancellation token if it's non-default. + // This allows for the default timeout to be applied for playable beatmap construction. + Beatmap = cancellationToken == default + ? beatmap.GetPlayableBeatmap(ruleset, playableMods) + : beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); var track = new TrackVirtual(10000); playableMods.OfType().ForEach(m => m.ApplyToTrack(track)); diff --git a/osu.Game/Rulesets/Difficulty/PerformanceAttributes.cs b/osu.Game/Rulesets/Difficulty/PerformanceAttributes.cs new file mode 100644 index 0000000000..025b38257c --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/PerformanceAttributes.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 Newtonsoft.Json; + +namespace osu.Game.Rulesets.Difficulty +{ + public class PerformanceAttributes + { + /// + /// Calculated score performance points. + /// + [JsonProperty("pp")] + public double Total { get; set; } + } +} diff --git a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs index 58427f6945..6358ec18b7 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; @@ -37,6 +36,6 @@ namespace osu.Game.Rulesets.Difficulty TimeRate = track.Rate; } - public abstract double Calculate(Dictionary categoryDifficulty = null); + public abstract PerformanceAttributes Calculate(); } } diff --git a/osu.Game/Rulesets/Edit/ToolboxGroup.cs b/osu.Game/Rulesets/Edit/EditorToolboxGroup.cs similarity index 71% rename from osu.Game/Rulesets/Edit/ToolboxGroup.cs rename to osu.Game/Rulesets/Edit/EditorToolboxGroup.cs index 22b2b05657..bde426f56a 100644 --- a/osu.Game/Rulesets/Edit/ToolboxGroup.cs +++ b/osu.Game/Rulesets/Edit/EditorToolboxGroup.cs @@ -2,13 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Overlays; namespace osu.Game.Rulesets.Edit { - public class ToolboxGroup : PlayerSettingsGroup + public class EditorToolboxGroup : SettingsToolboxGroup { - public ToolboxGroup(string title) + public EditorToolboxGroup(string title) : base(title) { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 8bad9aa11f..92ea2db338 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -13,6 +13,7 @@ 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 +81,7 @@ namespace osu.Game.Rulesets.Edit [BackgroundDependencyLoader] private void load() { - Config = Dependencies.Get().GetConfigFor(Ruleset); + Config = Dependencies.Get().GetConfigFor(Ruleset); try { @@ -98,14 +99,11 @@ namespace osu.Game.Rulesets.Edit dependencies.CacheAs(Playfield); - const float toolbar_width = 200; - InternalChildren = new Drawable[] { new Container { Name = "Content", - Padding = new MarginPadding { Left = toolbar_width }, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -117,20 +115,15 @@ namespace osu.Game.Rulesets.Edit .WithChild(BlueprintContainer = CreateBlueprintContainer()) } }, - new FillFlowContainer + new LeftToolboxFlow { - Name = "Sidebar", - RelativeSizeAxes = Axes.Y, - Width = toolbar_width, - Padding = new MarginPadding { Right = 10 }, - Spacing = new Vector2(10), Children = new Drawable[] { - new ToolboxGroup("toolbox (1-9)") + new EditorToolboxGroup("toolbox (1-9)") { Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } }, - new ToolboxGroup("toggles (Q~P)") + new EditorToolboxGroup("toggles (Q~P)") { Child = togglesCollection = new FillFlowContainer { @@ -427,6 +420,18 @@ namespace osu.Game.Rulesets.Edit } #endregion + + private class LeftToolboxFlow : ExpandingButtonContainer + { + public LeftToolboxFlow() + : base(80, 200) + { + RelativeSizeAxes = Axes.Y; + Padding = new MarginPadding { Right = 10 }; + + FillFlow.Spacing = new Vector2(10); + } + } } /// diff --git a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs index a54f574bff..9998a997b3 100644 --- a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs +++ b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs @@ -7,7 +7,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Rulesets.Edit { - public class ScrollingToolboxGroup : ToolboxGroup + public class ScrollingToolboxGroup : EditorToolboxGroup { protected readonly OsuScrollContainer Scroll; diff --git a/osu.Game/Rulesets/IRulesetConfigCache.cs b/osu.Game/Rulesets/IRulesetConfigCache.cs new file mode 100644 index 0000000000..b946b43905 --- /dev/null +++ b/osu.Game/Rulesets/IRulesetConfigCache.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. + +#nullable enable + +using osu.Game.Rulesets.Configuration; + +namespace osu.Game.Rulesets +{ + /// + /// A cache that provides a single per-ruleset. + /// This is done to support referring to and updating ruleset configs from multiple locations in the absence of inter-config bindings. + /// + public interface IRulesetConfigCache + { + /// + /// Retrieves the for a . + /// + /// The to retrieve the for. + /// The defined by , null if doesn't define one. + public IRulesetConfigManager? GetConfigFor(Ruleset ruleset); + } +} diff --git a/osu.Game/Rulesets/IRulesetInfo.cs b/osu.Game/Rulesets/IRulesetInfo.cs index 4e529a73fb..6599e0d59d 100644 --- a/osu.Game/Rulesets/IRulesetInfo.cs +++ b/osu.Game/Rulesets/IRulesetInfo.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Database; #nullable enable @@ -10,7 +11,7 @@ namespace osu.Game.Rulesets /// /// A representation of a ruleset's metadata. /// - public interface IRulesetInfo : IHasOnlineID + public interface IRulesetInfo : IHasOnlineID, IEquatable { /// /// The user-exposed name of this ruleset. diff --git a/osu.Game/Rulesets/IRulesetStore.cs b/osu.Game/Rulesets/IRulesetStore.cs new file mode 100644 index 0000000000..08d907810b --- /dev/null +++ b/osu.Game/Rulesets/IRulesetStore.cs @@ -0,0 +1,31 @@ +// 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; + +#nullable enable + +namespace osu.Game.Rulesets +{ + public interface IRulesetStore + { + /// + /// Retrieve a ruleset using a known ID. + /// + /// The ruleset's internal ID. + /// A ruleset, if available, else null. + IRulesetInfo? GetRuleset(int id); + + /// + /// Retrieve a ruleset using a known short name. + /// + /// The ruleset's short name. + /// A ruleset, if available, else null. + IRulesetInfo? GetRuleset(string shortName); + + /// + /// All available rulesets. + /// + IEnumerable AvailableRulesets { get; } + } +} diff --git a/osu.Game/Rulesets/Mods/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs index 1fde5abad4..8a9b0cddc8 100644 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ b/osu.Game/Rulesets/Mods/ModBlockFail.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToHUD(HUDOverlay overlay) { - overlay.ShowHealthbar.BindTo(showHealthBar); + overlay.ShowHealthBar.BindTo(showHealthBar); } } } diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index c78088ba2d..f28ef1edeb 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Mods drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); // AlwaysPresent required for hitsounds - drawableRuleset.Playfield.AlwaysPresent = true; - drawableRuleset.Playfield.Hide(); + drawableRuleset.AlwaysPresent = true; + drawableRuleset.Hide(); } } diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index d12f48e973..d4c4dce0f5 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Double Time"; public override string Acronym => "DT"; - public override IconUsage? Icon => OsuIcon.ModDoubletime; + public override IconUsage? Icon => OsuIcon.ModDoubleTime; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Zoooooooooom..."; diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index da838f9ea6..0a5348a8cf 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Hard Rock"; public override string Acronym => "HR"; - public override IconUsage? Icon => OsuIcon.ModHardrock; + public override IconUsage? Icon => OsuIcon.ModHardRock; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index abf67c2e2d..5ebae17228 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "No Fail"; public override string Acronym => "NF"; - public override IconUsage? Icon => OsuIcon.ModNofail; + 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 double ScoreMultiplier => 0.5; diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index 1abd353d20..c8b835f78a 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Sudden Death"; public override string Acronym => "SD"; - public override IconUsage? Icon => OsuIcon.ModSuddendeath; + public override IconUsage? Icon => OsuIcon.ModSuddenDeath; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Miss and fail."; public override double ScoreMultiplier => 1; diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 01817147ae..5531bf8b5a 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -123,9 +123,9 @@ namespace osu.Game.Rulesets.Objects.Drawables public readonly Bindable StartTimeBindable = new Bindable(); private readonly BindableList samplesBindable = new BindableList(); - private readonly Bindable userPositionalHitSounds = new Bindable(); - private readonly Bindable comboIndexBindable = new Bindable(); + + private readonly Bindable positionalHitsoundsLevel = new Bindable(); private readonly Bindable comboIndexWithOffsetsBindable = new Bindable(); protected override bool RequiresChildrenUpdate => true; @@ -168,7 +168,7 @@ namespace osu.Game.Rulesets.Objects.Drawables [BackgroundDependencyLoader] private void load(OsuConfigManager config, ISkinSource skinSource) { - config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds); + config.BindWith(OsuSetting.PositionalHitsoundsLevel, positionalHitsoundsLevel); // Explicit non-virtual function call. base.AddInternal(Samples = new PausableSkinnableSound()); @@ -532,9 +532,10 @@ namespace osu.Game.Rulesets.Objects.Drawables /// The lookup X position. Generally should be . protected double CalculateSamplePlaybackBalance(double position) { - const float balance_adjust_amount = 0.4f; + float balanceAdjustAmount = positionalHitsoundsLevel.Value * 2; + double returnedValue = balanceAdjustAmount * (position - 0.5f); - return balance_adjust_amount * (userPositionalHitSounds.Value ? position - 0.5f : 0); + return returnedValue; } /// diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index a80b3d0fa5..c590cc302f 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -87,23 +87,6 @@ namespace osu.Game.Rulesets.Objects [JsonIgnore] public SlimReadOnlyListWrapper NestedHitObjects => nestedHitObjects.AsSlimReadOnly(); - public HitObject() - { - StartTimeBindable.ValueChanged += time => - { - double offset = time.NewValue - time.OldValue; - - foreach (var nested in nestedHitObjects) - nested.StartTime += offset; - - if (DifficultyControlPoint != DifficultyControlPoint.DEFAULT) - DifficultyControlPoint.Time = time.NewValue; - - if (SampleControlPoint != SampleControlPoint.DEFAULT) - SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; - }; - } - /// /// Applies default values to this HitObject. /// @@ -115,24 +98,22 @@ namespace osu.Game.Rulesets.Objects var legacyInfo = controlPointInfo as LegacyControlPointInfo; if (legacyInfo != null) - { DifficultyControlPoint = (DifficultyControlPoint)legacyInfo.DifficultyPointAt(StartTime).DeepClone(); - DifficultyControlPoint.Time = StartTime; - } else if (DifficultyControlPoint == DifficultyControlPoint.DEFAULT) DifficultyControlPoint = new DifficultyControlPoint(); + DifficultyControlPoint.Time = StartTime; + ApplyDefaultsToSelf(controlPointInfo, difficulty); // This is done here after ApplyDefaultsToSelf as we may require custom defaults to be applied to have an accurate end time. if (legacyInfo != null) - { SampleControlPoint = (SampleControlPoint)legacyInfo.SamplePointAt(this.GetEndTime() + control_point_leniency).DeepClone(); - SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; - } else if (SampleControlPoint == SampleControlPoint.DEFAULT) SampleControlPoint = new SampleControlPoint(); + SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; + nestedHitObjects.Clear(); CreateNestedHitObjects(cancellationToken); @@ -155,7 +136,28 @@ namespace osu.Game.Rulesets.Objects foreach (var h in nestedHitObjects) h.ApplyDefaults(controlPointInfo, difficulty, cancellationToken); + // `ApplyDefaults()` may be called multiple times on a single hitobject. + // to prevent subscribing to `StartTimeBindable.ValueChanged` multiple times with the same callback, + // remove the previous subscription (if present) before (re-)registering. + StartTimeBindable.ValueChanged -= onStartTimeChanged; + + // this callback must be (re-)registered after default application + // to ensure that the read of `this.GetEndTime()` within `onStartTimeChanged` doesn't return an invalid value + // if `StartTimeBindable` is changed prior to default application. + StartTimeBindable.ValueChanged += onStartTimeChanged; + DefaultsApplied?.Invoke(this); + + void onStartTimeChanged(ValueChangedEvent time) + { + double offset = time.NewValue - time.OldValue; + + foreach (var nested in nestedHitObjects) + nested.StartTime += offset; + + DifficultyControlPoint.Time = time.NewValue; + SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; + } } protected virtual void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index cf19d64080..b091803406 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -188,12 +188,12 @@ namespace osu.Game.Rulesets.Objects.Legacy string[] split = str.Split(':'); var bank = (LegacySampleBank)Parsing.ParseInt(split[0]); - var addbank = (LegacySampleBank)Parsing.ParseInt(split[1]); + var addBank = (LegacySampleBank)Parsing.ParseInt(split[1]); string stringBank = bank.ToString().ToLowerInvariant(); if (stringBank == @"none") stringBank = null; - string stringAddBank = addbank.ToString().ToLowerInvariant(); + string stringAddBank = addBank.ToString().ToLowerInvariant(); if (stringAddBank == @"none") stringAddBank = null; diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 0dec0655b9..e0c62fe359 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -250,13 +250,13 @@ namespace osu.Game.Rulesets.Objects if (subControlPoints.Length != 3) break; - List subpath = PathApproximator.ApproximateCircularArc(subControlPoints); + List subPath = PathApproximator.ApproximateCircularArc(subControlPoints); // If for some reason a circular arc could not be fit to the 3 given points, fall back to a numerically stable bezier approximation. - if (subpath.Count == 0) + if (subPath.Count == 0) break; - return subpath; + return subPath; case PathType.Catmull: return PathApproximator.ApproximateCatmull(subControlPoints); diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index 1308fff7ae..ba614900c0 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -11,6 +12,15 @@ namespace osu.Game.Rulesets.Objects { public static class SliderPathExtensions { + /// + /// Snaps the provided 's duration using the . + /// + public static void SnapTo(this THitObject hitObject, IPositionSnapProvider? snapProvider) + where THitObject : HitObject, IHasPath + { + hitObject.Path.ExpectedDistance.Value = snapProvider?.GetSnappedDistanceFromDistance(hitObject, (float)hitObject.Path.CalculatedDistance) ?? hitObject.Path.CalculatedDistance; + } + /// /// Reverse the direction of this path. /// diff --git a/osu.Game/Rulesets/RulesetConfigCache.cs b/osu.Game/Rulesets/RulesetConfigCache.cs index 262340b4ee..dee13e74a5 100644 --- a/osu.Game/Rulesets/RulesetConfigCache.cs +++ b/osu.Game/Rulesets/RulesetConfigCache.cs @@ -6,15 +6,12 @@ using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Rulesets.Configuration; namespace osu.Game.Rulesets { - /// - /// A cache that provides a single per-ruleset. - /// This is done to support referring to and updating ruleset configs from multiple locations in the absence of inter-config bindings. - /// - public class RulesetConfigCache : Component + public class RulesetConfigCache : Component, IRulesetConfigCache { private readonly RealmContextFactory realmFactory; private readonly RulesetStore rulesets; @@ -43,18 +40,13 @@ namespace osu.Game.Rulesets } } - /// - /// Retrieves the for a . - /// - /// The to retrieve the for. - /// The defined by , null if doesn't define one. - /// If doesn't have a valid . public IRulesetConfigManager GetConfigFor(Ruleset ruleset) { + if (!IsLoaded) + throw new InvalidOperationException($@"Cannot retrieve {nameof(IRulesetConfigManager)} before {nameof(RulesetConfigCache)} has loaded"); + if (!configCache.TryGetValue(ruleset.RulesetInfo.ShortName, out var config)) - // any ruleset request which wasn't initialised on startup should not be stored to realm. - // this should only be used by tests. - return ruleset.CreateConfig(null); + throw new InvalidOperationException($@"Attempted to retrieve {nameof(IRulesetConfigManager)} for an unavailable ruleset {ruleset.GetDisplayString()}"); return config; } diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index 4a146c05bf..d018cc4194 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -49,6 +49,8 @@ namespace osu.Game.Rulesets public override bool Equals(object obj) => obj is RulesetInfo rulesetInfo && Equals(rulesetInfo); + public bool Equals(IRulesetInfo other) => other is RulesetInfo b && Equals(b); + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] public override int GetHashCode() { diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 6dd036c0e6..5cc6a75f43 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -13,7 +13,7 @@ using osu.Game.Database; namespace osu.Game.Rulesets { - public class RulesetStore : DatabaseBackedStore, IDisposable + public class RulesetStore : DatabaseBackedStore, IRulesetStore, IDisposable { private const string ruleset_library_prefix = "osu.Game.Rulesets"; @@ -236,5 +236,13 @@ namespace osu.Game.Rulesets { AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } + + #region Implementation of IRulesetStore + + IRulesetInfo IRulesetStore.GetRuleset(int id) => GetRuleset(id); + IRulesetInfo IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName); + IEnumerable IRulesetStore.AvailableRulesets => AvailableRulesets; + + #endregion } } diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index ed4a16f0e8..c3c4a2c949 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Scoring if (result == null) throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - result.Type = judgement.MaxResult; + result.Type = GetSimulatedHitResult(judgement); ApplyResult(result); } } @@ -145,5 +145,12 @@ namespace osu.Game.Rulesets.Scoring base.Update(); hasCompleted.Value = JudgedHits == MaxHits && (JudgedHits == 0 || lastAppliedResult.TimeAbsolute < Clock.CurrentTime); } + + /// + /// Gets a simulated for a judgement. Used during to simulate a "perfect" play. + /// + /// The judgement to simulate a for. + /// The simulated for the judgement. + protected virtual HitResult GetSimulatedHitResult(Judgement judgement) => judgement.MaxResult; } } diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index e49e515d2e..53996b6b84 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -63,7 +64,7 @@ namespace osu.Game.Rulesets.UI CacheAs(ShaderManager = new FallbackShaderManager(ShaderManager, parent.Get())); } - RulesetConfigManager = parent.Get().GetConfigFor(ruleset); + RulesetConfigManager = parent.Get().GetConfigFor(ruleset); if (RulesetConfigManager != null) Cache(RulesetConfigManager); } @@ -115,7 +116,7 @@ namespace osu.Game.Rulesets.UI public Sample Get(string name) => primary.Get(name) ?? fallback.Get(name); - public Task GetAsync(string name) => primary.GetAsync(name) ?? fallback.GetAsync(name); + public Task GetAsync(string name, CancellationToken cancellationToken = default) => primary.GetAsync(name, cancellationToken) ?? fallback.GetAsync(name, cancellationToken); public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index c0b339a231..f2dbb1a23f 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.UI private int direction = 1; [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler) + private void load(GameplayClock clock) { if (clock != null) { diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 52aecb27de..d0bbf859af 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -19,7 +19,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Skinning; using osuTK; using System.Diagnostics; -using osu.Framework.Audio.Sample; namespace osu.Game.Rulesets.UI { @@ -88,9 +87,6 @@ namespace osu.Game.Rulesets.UI [Resolved(CanBeNull = true)] private IReadOnlyList mods { get; set; } - [Resolved] - private ISampleStore sampleStore { get; set; } - /// /// Creates a new . /// diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 6564ff9e23..370c99ffaf 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -127,6 +127,17 @@ namespace osu.Game.Rulesets.UI return base.Handle(e); } + protected override bool HandleMouseTouchStateChange(TouchStateChangeEvent e) + { + if (mouseDisabled.Value) + { + // Only propagate positional data when mouse buttons are disabled. + e = new TouchStateChangeEvent(e.State, e.Input, e.Touch, false, e.LastPosition); + } + + return base.HandleMouseTouchStateChange(e); + } + #endregion #region Key Counter Attachment diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 1f3a937311..926f2fd539 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -116,25 +116,11 @@ namespace osu.Game.Rulesets.UI.Scrolling if (RelativeScaleBeatLengths) { - IReadOnlyList timingPoints = Beatmap.ControlPointInfo.TimingPoints; - double maxDuration = 0; + baseBeatLength = Beatmap.GetMostCommonBeatLength(); - for (int i = 0; i < timingPoints.Count; i++) - { - if (timingPoints[i].Time > lastObjectTime) - break; - - double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time : lastObjectTime; - double duration = endTime - timingPoints[i].Time; - - if (duration > maxDuration) - { - maxDuration = duration; - // The slider multiplier is post-multiplied to determine the final velocity, but for relative scale beat lengths - // the multiplier should not affect the effective timing point (the longest in the beatmap), so it is factored out here - baseBeatLength = timingPoints[i].BeatLength / Beatmap.Difficulty.SliderMultiplier; - } - } + // The slider multiplier is post-multiplied to determine the final velocity, but for relative scale beat lengths + // the multiplier should not affect the effective timing point (the longest in the beatmap), so it is factored out here + baseBeatLength /= Beatmap.Difficulty.SliderMultiplier; } // Merge sequences of timing and difficulty control points to create the aggregate "multiplier" control point diff --git a/osu.Game/Scoring/IScoreInfo.cs b/osu.Game/Scoring/IScoreInfo.cs index 8b5b228632..b4ad183cd3 100644 --- a/osu.Game/Scoring/IScoreInfo.cs +++ b/osu.Game/Scoring/IScoreInfo.cs @@ -4,14 +4,14 @@ using System; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Users; namespace osu.Game.Scoring { public interface IScoreInfo : IHasOnlineID, IHasNamedFiles { - APIUser User { get; } + IUser User { get; } long TotalScore { get; } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index f943422389..fefee370b9 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -80,12 +80,9 @@ namespace osu.Game.Scoring.Legacy byte[] compressedReplay = sr.ReadByteArray(); if (version >= 20140721) - scoreInfo.OnlineScoreID = sr.ReadInt64(); + scoreInfo.OnlineID = sr.ReadInt64(); else if (version >= 20121008) - scoreInfo.OnlineScoreID = sr.ReadInt32(); - - if (scoreInfo.OnlineScoreID <= 0) - scoreInfo.OnlineScoreID = null; + scoreInfo.OnlineID = sr.ReadInt32(); if (compressedReplay?.Length > 0) { diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 7b8cacb35b..3d67aa9558 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -46,7 +46,7 @@ namespace osu.Game.Scoring.Legacy sw.Write(LATEST_VERSION); sw.Write(score.ScoreInfo.BeatmapInfo.MD5Hash); sw.Write(score.ScoreInfo.UserString); - sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash()); + sw.Write(FormattableString.Invariant($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}").ComputeMD5Hash()); sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0)); sw.Write((ushort)(score.ScoreInfo.GetCount100() ?? 0)); sw.Write((ushort)(score.ScoreInfo.GetCount50() ?? 0)); @@ -110,7 +110,9 @@ namespace osu.Game.Scoring.Legacy } } - replayData.AppendFormat(@"{0}|{1}|{2}|{3},", -12345, 0, 0, 0); + // Warning: this is purposefully hardcoded as a string rather than interpolating, as in some cultures the minus sign is not encoded as the standard ASCII U+00C2 codepoint, + // which then would break decoding. + replayData.Append(@"-12345|0|0|0"); return replayData.ToString(); } } diff --git a/osu.Game/Scoring/ScoreFileInfo.cs b/osu.Game/Scoring/ScoreFileInfo.cs index b2e81d4b8d..4c88cfa021 100644 --- a/osu.Game/Scoring/ScoreFileInfo.cs +++ b/osu.Game/Scoring/ScoreFileInfo.cs @@ -11,6 +11,8 @@ namespace osu.Game.Scoring { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int FileInfoID { get; set; } public FileInfo FileInfo { get; set; } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 564aa3b98c..7acc7bd055 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -14,6 +14,7 @@ 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 @@ -22,6 +23,8 @@ namespace osu.Game.Scoring { public int ID { get; set; } + public bool IsManaged => ID > 0; + public ScoreRank Rank { get; set; } public long TotalScore { get; set; } @@ -134,7 +137,14 @@ namespace osu.Game.Scoring [Column("Beatmap")] public BeatmapInfo BeatmapInfo { get; set; } - public long? OnlineScoreID { get; set; } + private long? onlineID; + + [Column("OnlineScoreID")] + public long? OnlineID + { + get => onlineID; + set => onlineID = value > 0 ? value : null; + } public DateTimeOffset Date { get; set; } @@ -229,24 +239,18 @@ namespace osu.Game.Scoring public bool Equals(ScoreInfo other) { - if (other == null) - return false; + if (ReferenceEquals(this, other)) return true; + if (other == null) return false; if (ID != 0 && other.ID != 0) return ID == other.ID; - if (OnlineScoreID.HasValue && other.OnlineScoreID.HasValue) - return OnlineScoreID == other.OnlineScoreID; - - if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) - return Hash == other.Hash; - - return ReferenceEquals(this, other); + return false; } #region Implementation of IHasOnlineID - public long OnlineID => OnlineScoreID ?? -1; + long IHasOnlineID.OnlineID => OnlineID ?? -1; #endregion @@ -254,6 +258,7 @@ namespace osu.Game.Scoring IBeatmapInfo IScoreInfo.Beatmap => BeatmapInfo; IRulesetInfo IScoreInfo.Ruleset => Ruleset; + IUser IScoreInfo.User => User; bool IScoreInfo.HasReplay => Files.Any(); #endregion diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index e9cd44ae83..39cd28cad2 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -9,6 +9,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; @@ -71,7 +72,7 @@ namespace osu.Game.Scoring return scores.Select((score, index) => (score, totalScore: totalScores[index])) .OrderByDescending(g => g.totalScore) - .ThenBy(g => g.score.OnlineScoreID) + .ThenBy(g => g.score.OnlineID) .Select(g => g.score) .ToArray(); } @@ -112,7 +113,7 @@ namespace osu.Game.Scoring public void GetTotalScore([NotNull] ScoreInfo score, [NotNull] Action callback, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) { GetTotalScoreAsync(score, mode, cancellationToken) - .ContinueWith(s => scheduler.Add(() => callback(s.Result)), TaskContinuationOptions.OnlyOnRanToCompletion); + .ContinueWith(task => scheduler.Add(() => callback(task.GetResultSafely())), TaskContinuationOptions.OnlyOnRanToCompletion); } /// diff --git a/osu.Game/Scoring/ScoreModelDownloader.cs b/osu.Game/Scoring/ScoreModelDownloader.cs index 038a4bc351..514b7a57de 100644 --- a/osu.Game/Scoring/ScoreModelDownloader.cs +++ b/osu.Game/Scoring/ScoreModelDownloader.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -17,6 +18,6 @@ namespace osu.Game.Scoring protected override ArchiveDownloadRequest CreateDownloadRequest(IScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score); public override ArchiveDownloadRequest GetExistingDownload(IScoreInfo model) - => CurrentDownloads.Find(r => r.Model.OnlineID == model.OnlineID); + => CurrentDownloads.Find(r => r.Model.MatchesOnlineID(model)); } } diff --git a/osu.Game/Scoring/ScoreModelManager.cs b/osu.Game/Scoring/ScoreModelManager.cs index 2cbd3aded7..44f0fe4fdf 100644 --- a/osu.Game/Scoring/ScoreModelManager.cs +++ b/osu.Game/Scoring/ScoreModelManager.cs @@ -66,6 +66,6 @@ namespace osu.Game.Scoring protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable items) => base.CheckLocalAvailability(model, items) - || (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID)); + || (model.OnlineID > 0 && items.Any(i => i.OnlineID == model.OnlineID)); } } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index 79fb47f4b0..b855343505 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -44,7 +44,7 @@ namespace osu.Game.Scoring var calculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(attributes.Value.Attributes, score); - return calculator?.Calculate(); + return calculator?.Calculate().Total; } public readonly struct PerformanceCacheLookup diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 4a922c45b9..452f033dcc 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -48,16 +48,19 @@ namespace osu.Game.Screens.Backgrounds AddInternal(seasonalBackgroundLoader); - user.ValueChanged += _ => Next(); - skin.ValueChanged += _ => Next(); - mode.ValueChanged += _ => Next(); - beatmap.ValueChanged += _ => Next(); - introSequence.ValueChanged += _ => Next(); - seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Next(); + user.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + skin.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + mode.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + beatmap.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + introSequence.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Scheduler.AddOnce(loadNextIfRequired); currentDisplay = RNG.Next(0, background_count); Next(); + + // helper function required for AddOnce usage. + void loadNextIfRequired() => Next(); } private ScheduledDelegate nextTask; @@ -67,7 +70,7 @@ namespace osu.Game.Screens.Backgrounds /// Request loading the next background. /// /// Whether a new background was queued for load. May return false if the current background is still valid. - public bool Next() + public virtual bool Next() { var nextBackground = createBackground(); diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs index 4629f9b540..f0e643f805 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs @@ -1,20 +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.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { public class GroupVisualisation : CompositeDrawable { - [Resolved] - private OsuColour colours { get; set; } - public readonly ControlPointGroup Group; private readonly IBindableList controlPoints = new BindableList(); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index be52a968bb..cda986c7cd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -15,13 +15,14 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { + [Cached] public class SelectionBox : CompositeDrawable { public const float BORDER_RADIUS = 3; public Func OnRotation; public Func OnScale; - public Func OnFlip; + public Func OnFlip; public Func OnReverse; public Action OperationStarted; @@ -174,12 +175,6 @@ namespace osu.Game.Screens.Edit.Compose.Components { case Key.G: return CanReverse && runOperationFromHotkey(OnReverse); - - case Key.H: - return CanFlipX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); - - case Key.J: - return CanFlipY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); } return base.OnKeyDown(e); @@ -287,12 +282,12 @@ namespace osu.Game.Screens.Edit.Compose.Components private void addXFlipComponents() { - addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); + addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally", () => OnFlip?.Invoke(Direction.Horizontal, false)); } private void addYFlipComponents() { - addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); + addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically", () => OnFlip?.Invoke(Direction.Vertical, false)); } private void addButton(IconUsage icon, string tooltip, Action action) @@ -312,7 +307,7 @@ namespace osu.Game.Screens.Edit.Compose.Components var handle = new SelectionBoxScaleHandle { Anchor = anchor, - HandleDrag = e => OnScale?.Invoke(e.Delta, anchor) + HandleScale = (delta, a) => OnScale?.Invoke(delta, a) }; handle.OperationStarted += operationStarted; @@ -325,7 +320,7 @@ namespace osu.Game.Screens.Edit.Compose.Components var handle = new SelectionBoxRotationHandle { Anchor = anchor, - HandleDrag = e => OnRotation?.Invoke(convertDragEventToAngleOfRotation(e)) + HandleRotate = angle => OnRotation?.Invoke(angle) }; handle.OperationStarted += operationStarted; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index 3ac40fda0f..346bb2b508 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -49,7 +49,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { TriggerOperationStarted(); Action?.Invoke(); - TriggerOperatoinEnded(); + TriggerOperationEnded(); return true; } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs index 40d367bb80..8f22e67922 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs @@ -92,6 +92,6 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void TriggerOperationStarted() => OperationStarted?.Invoke(); - protected void TriggerOperatoinEnded() => OperationEnded?.Invoke(); + protected void TriggerOperationEnded() => OperationEnded?.Invoke(); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs index 65a95951cf..c37fefeed4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs @@ -8,23 +8,15 @@ namespace osu.Game.Screens.Edit.Compose.Components { public abstract class SelectionBoxDragHandle : SelectionBoxControl { - public Action HandleDrag { get; set; } - protected override bool OnDragStart(DragStartEvent e) { TriggerOperationStarted(); return true; } - protected override void OnDrag(DragEvent e) - { - HandleDrag?.Invoke(e); - base.OnDrag(e); - } - protected override void OnDragEnd(DragEndEvent e) { - TriggerOperatoinEnded(); + TriggerOperationEnded(); UpdateHoverState(); base.OnDragEnd(e); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index 65a54292ab..22479bd9b3 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -1,19 +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 System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components { - public class SelectionBoxRotationHandle : SelectionBoxDragHandle + public class SelectionBoxRotationHandle : SelectionBoxDragHandle, IHasTooltip { + public Action HandleRotate { get; set; } + + public LocalisableString TooltipText { get; private set; } + private SpriteIcon icon; + private readonly Bindable cumulativeRotation = new Bindable(); + + [Resolved] + private SelectionBox selectionBox { get; set; } + [BackgroundDependencyLoader] private void load() { @@ -33,10 +48,59 @@ namespace osu.Game.Screens.Edit.Compose.Components }); } + protected override void LoadComplete() + { + base.LoadComplete(); + cumulativeRotation.BindValueChanged(_ => updateTooltipText(), true); + } + protected override void UpdateHoverState() { base.UpdateHoverState(); icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); } + + protected override bool OnDragStart(DragStartEvent e) + { + bool handle = base.OnDragStart(e); + if (handle) + cumulativeRotation.Value = 0; + return handle; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + float instantaneousAngle = convertDragEventToAngleOfRotation(e); + cumulativeRotation.Value += instantaneousAngle; + + if (cumulativeRotation.Value < -180) + cumulativeRotation.Value += 360; + else if (cumulativeRotation.Value > 180) + cumulativeRotation.Value -= 360; + + HandleRotate?.Invoke(instantaneousAngle); + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + cumulativeRotation.Value = null; + } + + private float convertDragEventToAngleOfRotation(DragEvent e) + { + // Adjust coordinate system to the center of SelectionBox + float startAngle = MathF.Atan2(e.LastMousePosition.Y - selectionBox.DrawHeight / 2, e.LastMousePosition.X - selectionBox.DrawWidth / 2); + float endAngle = MathF.Atan2(e.MousePosition.Y - selectionBox.DrawHeight / 2, e.MousePosition.X - selectionBox.DrawWidth / 2); + + return (endAngle - startAngle) * 180 / MathF.PI; + } + + private void updateTooltipText() + { + TooltipText = cumulativeRotation.Value?.ToLocalisableString("0.0°") ?? default(LocalisableString); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs index a87c661f45..1f82f28380 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs @@ -1,17 +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; using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { public class SelectionBoxScaleHandle : SelectionBoxDragHandle { + public Action HandleScale { get; set; } + [BackgroundDependencyLoader] private void load() { Size = new Vector2(10); } + + protected override void OnDrag(DragEvent e) + { + HandleScale?.Invoke(e.Delta, Anchor); + base.OnDrag(e); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index ee35b6a47c..9d5d8013b7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -15,8 +15,8 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osuTK; using osuTK.Input; @@ -26,7 +26,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A component which outlines items and handles movement of selections. /// - public abstract class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IHasContextMenu + public abstract class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IKeyBindingHandler, IHasContextMenu { /// /// The currently selected blueprints. @@ -56,7 +56,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { InternalChild = SelectionBox = CreateSelectionBox(); @@ -127,9 +127,10 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Handles the selected items being flipped. /// - /// The direction to flip + /// The direction to flip. + /// Whether the flip operation should be global to the playfield's origin or local to the selected pattern. /// Whether any items could be flipped. - public virtual bool HandleFlip(Direction direction) => false; + public virtual bool HandleFlip(Direction direction, bool flipOverOrigin) => false; /// /// Handles the selected items being reversed pattern-wise. @@ -137,6 +138,27 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether any items could be reversed. public virtual bool HandleReverse() => false; + public virtual bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.EditorFlipHorizontally: + return HandleFlip(Direction.Horizontal, true); + + case GlobalAction.EditorFlipVertically: + return HandleFlip(Direction.Vertical, true); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 2cbfe88519..7d52645aa1 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -15,7 +15,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Timing; @@ -39,7 +38,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { volume.BindValueChanged(volume => updateText()); bank.BindValueChanged(bank => updateText(), true); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index b8fa05e7eb..265f56534f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -279,9 +279,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline editorClock.Start(); } - [Resolved] - private EditorBeatmap beatmap { get; set; } - [Resolved] private IBeatSnapProvider beatSnapProvider { get; set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs index 2b2e66fb18..9610f6424c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.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. -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -16,9 +14,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly IBindableList controlPoints = new BindableList(); - [Resolved] - private OsuColour colours { get; set; } - public TimelineControlPointGroup(ControlPointGroup group) { Group = group; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 80aa6972b1..6426d33e99 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -177,16 +177,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline else circle.Colour = colour; - var col = circle.Colour.TopLeft.Linear; - colouredComponents.Colour = OsuColour.ForegroundTextColourFor(col); + var averageColour = Interpolation.ValueAt(0.5, circle.Colour.TopLeft, circle.Colour.TopRight, 0, 1); + colouredComponents.Colour = OsuColour.ForegroundTextColourFor(averageColour); } private SamplePointPiece sampleOverrideDisplay; private DifficultyPointPiece difficultyOverrideDisplay; - [Resolved] - private EditorBeatmap beatmap { get; set; } - private DifficultyControlPoint difficultyControlPoint; private SampleControlPoint sampleControlPoint; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs index 845a671e2c..e98cf8332f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; @@ -17,7 +16,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler + internal class TimelineSelectionHandler : EditorSelectionHandler { [Resolved] private Timeline timeline { get; set; } @@ -27,7 +26,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; - public bool OnPressed(KeyBindingPressEvent e) + public override bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { @@ -40,11 +39,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return true; } - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { + return base.OnPressed(e); } /// diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs index fa51281c55..2df4ef001c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -19,7 +18,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { beatLength.BindValueChanged(beatLength => { diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 3b02d42b41..9386538a78 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -83,7 +83,9 @@ namespace osu.Game.Screens.Edit.Compose { base.LoadComplete(); EditorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) => updateClipboardActionAvailability()); - clipboard.BindValueChanged(_ => updateClipboardActionAvailability(), true); + clipboard.BindValueChanged(_ => updateClipboardActionAvailability()); + composer.OnLoadComplete += _ => updateClipboardActionAvailability(); + updateClipboardActionAvailability(); } #region Clipboard operations @@ -131,7 +133,7 @@ namespace osu.Game.Screens.Edit.Compose private void updateClipboardActionAvailability() { CanCut.Value = CanCopy.Value = EditorBeatmap.SelectedHitObjects.Any(); - CanPaste.Value = !string.IsNullOrEmpty(clipboard.Value); + CanPaste.Value = composer.IsLoaded && !string.IsNullOrEmpty(clipboard.Value); } private string formatSelectionAsString() diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index ac71298f36..48489c60ab 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -109,9 +109,6 @@ namespace osu.Game.Screens.Edit [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private MusicController music { get; set; } - [Cached] public readonly EditorClipboard Clipboard = new EditorClipboard(); diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs index 2a01a5b6b2..15d70e28b6 100644 --- a/osu.Game/Screens/Edit/EditorLoader.cs +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,6 +10,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; @@ -53,6 +55,14 @@ namespace osu.Game.Screens.Edit }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // will be restored via lease, see `DisallowExternalBeatmapRulesetChanges`. + Mods.Value = Array.Empty(); + } + protected virtual Editor CreateEditor() => new Editor(this); protected override void LogoArriving(OsuLogo logo, bool resuming) diff --git a/osu.Game/Screens/Edit/EditorRoundedScreen.cs b/osu.Game/Screens/Edit/EditorRoundedScreen.cs index 7f7b3abc2a..62f40f0325 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreen.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreen.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Overlays; namespace osu.Game.Screens.Edit @@ -14,9 +13,6 @@ namespace osu.Game.Screens.Edit { public const int HORIZONTAL_PADDING = 100; - [Resolved] - private OsuColour colours { get; set; } - private Container roundedContent; protected override Container Content => roundedContent; diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs index e17114ebcb..25d7dfbb4a 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; using osuTK; namespace osu.Game.Screens.Edit @@ -20,7 +19,7 @@ namespace osu.Game.Screens.Edit protected FillFlowContainer Flow { get; private set; } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) + private void load() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index ab8bd6a3bc..a67a060134 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -62,9 +62,6 @@ namespace osu.Game.Screens.Edit private readonly Box hoveredBackground; - [Resolved] - private EditorClock clock { get; set; } - public RowBackground(object item) { Item = item; diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index f833bc49f7..d1e35ae20d 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -15,7 +15,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Database; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK; @@ -36,9 +35,6 @@ namespace osu.Game.Screens.Edit.Setup [Resolved] private OsuGameBase game { get; set; } - [Resolved] - private SectionsContainer sectionsContainer { get; set; } - public FileChooserLabelledTextBox(params string[] handledExtensions) { this.handledExtensions = handledExtensions; diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs index 7304323004..17fb97d41f 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs @@ -56,7 +56,7 @@ namespace osu.Game.Screens.Edit.Setup { new Box { - Colour = colours.GreySeafoamDarker, + Colour = colours.GreySeaFoamDarker, RelativeSizeAxes = Axes.Both, }, new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 24)) diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 7a98cf63c3..1e6899e05f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -132,9 +132,6 @@ namespace osu.Game.Screens.Edit.Timing controlPoints.BindTo(group.ControlPoints); } - [Resolved] - private OsuColour colours { get; set; } - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index 14b8c4c9de..e25d83cfb0 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Edit.Timing } private readonly SettingsSlider slider; - private readonly LabelledTextBox textbox; + private readonly LabelledTextBox textBox; /// /// Creates an . @@ -66,7 +66,7 @@ namespace osu.Game.Screens.Edit.Timing Spacing = new Vector2(0, 5), Children = new Drawable[] { - textbox = new LabelledTextBox + textBox = new LabelledTextBox { Label = labelText, }, @@ -80,7 +80,7 @@ namespace osu.Game.Screens.Edit.Timing }, }; - textbox.OnCommit += (t, isNew) => + textBox.OnCommit += (t, isNew) => { if (!isNew) return; @@ -110,13 +110,13 @@ namespace osu.Game.Screens.Edit.Timing // use the value from the slider to ensure that any precision/min/max set on it via the initial indeterminate value have been applied correctly. decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); - textbox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); - textbox.PlaceholderText = string.Empty; + textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); + textBox.PlaceholderText = string.Empty; } else { - textbox.Text = null; - textbox.PlaceholderText = "(multiple)"; + textBox.Text = null; + textBox.PlaceholderText = "(multiple)"; } } } diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs index 6c004a7c8b..67f1dacec4 100644 --- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Globalization; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays.Settings; +using osu.Game.Utils; namespace osu.Game.Screens.Edit.Timing { @@ -19,7 +21,7 @@ namespace osu.Game.Screens.Edit.Timing public SliderWithTextBoxInput(LocalisableString labelText) { - LabelledTextBox textbox; + LabelledTextBox textBox; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -33,7 +35,7 @@ namespace osu.Game.Screens.Edit.Timing Direction = FillDirection.Vertical, Children = new Drawable[] { - textbox = new LabelledTextBox + textBox = new LabelledTextBox { Label = labelText, }, @@ -46,7 +48,7 @@ namespace osu.Game.Screens.Edit.Timing }, }; - textbox.OnCommit += (t, isNew) => + textBox.OnCommit += (t, isNew) => { if (!isNew) return; @@ -66,7 +68,8 @@ namespace osu.Game.Screens.Edit.Timing Current.BindValueChanged(val => { - textbox.Text = val.NewValue.ToString(); + decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); + textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); }, true); } diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index fd238feeac..cadcdebc6e 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -23,9 +23,6 @@ namespace osu.Game.Screens.Edit.Verify { private IssueTable table; - [Resolved] - private EditorClock clock { get; set; } - [Resolved] private IBindable workingBeatmap { get; set; } diff --git a/osu.Game/Screens/Edit/Verify/VisibilitySection.cs b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs index d049436376..0bdc8c0efd 100644 --- a/osu.Game/Screens/Edit/Verify/VisibilitySection.cs +++ b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Edit.Checks.Components; @@ -24,7 +23,7 @@ namespace osu.Game.Screens.Edit.Verify protected override string HeaderText => "Visibility"; [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours, VerifyScreen verify) + private void load(VerifyScreen verify) { hiddenIssueTypes = verify.HiddenIssueTypes.GetBoundCopy(); diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index 7e1d55b3e2..09870e0bab 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -9,7 +9,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -40,7 +39,7 @@ namespace osu.Game.Screens.Import private OsuColour colours { get; set; } [BackgroundDependencyLoader(true)] - private void load(Storage storage) + private void load() { InternalChild = contentContainer = new Container { @@ -54,7 +53,7 @@ namespace osu.Game.Screens.Import { new Box { - Colour = colours.GreySeafoamDark, + Colour = colours.GreySeaFoamDark, RelativeSizeAxes = Axes.Both, }, fileSelector = new OsuFileSelector(validFileExtensions: game.HandledExtensions.ToArray()) @@ -72,7 +71,7 @@ namespace osu.Game.Screens.Import { new Box { - Colour = colours.GreySeafoamDarker, + Colour = colours.GreySeaFoamDarker, RelativeSizeAxes = Axes.Both }, new Container diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index feb6f6c92a..32731407fd 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -15,7 +15,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; 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.Threading; @@ -123,7 +122,7 @@ namespace osu.Game.Screens.Menu private LoginOverlay loginOverlay { get; set; } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, IdleTracker idleTracker, GameHost host, LocalisationManager strings) + private void load(AudioManager audio, IdleTracker idleTracker, GameHost host) { buttonsPlay.Add(new Button(ButtonSystemStrings.Solo, @"button-solo-select", FontAwesome.Solid.User, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P)); buttonsPlay.Add(new Button(ButtonSystemStrings.Multi, @"button-generic-select", FontAwesome.Solid.Users, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M)); diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index eb8d3dfea6..59bf1785d5 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -9,6 +9,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Utils; @@ -17,7 +18,6 @@ using osu.Game.Configuration; using osu.Game.IO.Archives; using osu.Game.Overlays; using osu.Game.Screens.Backgrounds; -using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -80,7 +80,7 @@ namespace osu.Game.Screens.Menu } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, SkinManager skinManager, BeatmapManager beatmaps, Framework.Game game) + private void load(OsuConfigManager config, BeatmapManager beatmaps, Framework.Game game) { // prevent user from changing beatmap while the intro is still runnning. beatmap = Beatmap.BeginLease(false); @@ -110,7 +110,7 @@ namespace osu.Game.Screens.Menu { // if we detect that the theme track or beatmap is unavailable this is either first startup or things are in a bad state. // this could happen if a user has nuked their files store. for now, reimport to repair this. - var import = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).Result; + var import = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).GetResultSafely(); import.PerformWrite(b => { diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index d171e481b1..10f940e9de 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -133,7 +133,7 @@ namespace osu.Game.Screens.Menu private OsuGameBase game { get; set; } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load() { InternalChildren = new Drawable[] { diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index 01b2a98c6e..4acd73bfa0 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -53,7 +53,7 @@ namespace osu.Game.Screens.Menu /// /// How much should each bar go down each millisecond (based on a full bar). /// - private const float decay_per_milisecond = 0.0024f; + private const float decay_per_millisecond = 0.0024f; /// /// Number of milliseconds between each amplitude update. @@ -136,7 +136,7 @@ namespace osu.Game.Screens.Menu { base.Update(); - float decayFactor = (float)Time.Elapsed * decay_per_milisecond; + float decayFactor = (float)Time.Elapsed * decay_per_millisecond; for (int i = 0; i < bars_per_visualiser; i++) { diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 3da740b85d..8b1bab52b3 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -71,7 +71,7 @@ namespace osu.Game.Screens.Menu private SongTicker songTicker; [BackgroundDependencyLoader(true)] - private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, RankingsOverlay rankings, OsuConfigManager config, SessionStatics statics) + private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, OsuConfigManager config, SessionStatics statics) { holdDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); loginDisplayed = statics.GetBindable(Static.LoginOverlayDisplayed); diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs index dcaad4013a..250623ec68 100644 --- a/osu.Game/Screens/Menu/StorageErrorDialog.cs +++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs @@ -15,9 +15,6 @@ namespace osu.Game.Screens.Menu [Resolved] private DialogOverlay dialogOverlay { get; set; } - [Resolved] - private OsuGameBase osuGame { get; set; } - public StorageErrorDialog(OsuStorage storage, OsuStorageError error) { HeaderText = "osu! storage error"; diff --git a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs index b013cbafd8..89842e933b 100644 --- a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs +++ b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; using osuTK; @@ -43,9 +44,9 @@ namespace osu.Game.Screens.OnlinePlay.Components { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Bottom = 10 }, - Child = playlist = new DrawableRoomPlaylist(true, false) + Child = playlist = new PlaylistsRoomSettingsPlaylist { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Both } } }, diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs index e2ba0b03b0..d144e1e3a9 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.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. -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { @@ -30,7 +30,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private void updateBeatmap() { - sprite.Beatmap.Value = Playlist.FirstOrDefault()?.Beatmap.Value; + sprite.Beatmap.Value = Playlist.GetCurrentItem()?.Beatmap.Value; } protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(BeatmapSetCoverType) { RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index ddfdab18f7..238aa4059d 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -7,9 +7,9 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Logging; -using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -28,10 +28,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private readonly Bindable joinedRoom = new Bindable(); [Resolved] - private RulesetStore rulesets { get; set; } - - [Resolved] - private BeatmapManager beatmaps { get; set; } + private IRulesetStore rulesets { get; set; } [Resolved] private IAPIProvider api { get; set; } @@ -111,6 +108,7 @@ namespace osu.Game.Screens.OnlinePlay.Components public void AddOrUpdateRoom(Room room) { + Debug.Assert(ThreadSafety.IsUpdateThread); Debug.Assert(room.RoomID.Value != null); if (ignoredRooms.Contains(room.RoomID.Value.Value)) @@ -140,12 +138,16 @@ namespace osu.Game.Screens.OnlinePlay.Components public void RemoveRoom(Room room) { + Debug.Assert(ThreadSafety.IsUpdateThread); + rooms.Remove(room); notifyRoomsUpdated(); } public void ClearRooms() { + Debug.Assert(ThreadSafety.IsUpdateThread); + rooms.Clear(); notifyRoomsUpdated(); } diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs index b9d2bdf23e..22842fbb9e 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; -using osu.Framework.Allocation; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components @@ -12,9 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Components /// public class SelectionPollingComponent : RoomPollingComponent { - [Resolved] - private IRoomManager roomManager { get; set; } - private readonly Room room; public SelectionPollingComponent(Room room) diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index fc029543bb..edf9c5d155 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -80,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private void updateRange(object sender, NotifyCollectionChangedEventArgs e) { - var orderedDifficulties = Playlist.Select(p => p.Beatmap.Value).OrderBy(b => b.StarRating).ToArray(); + var orderedDifficulties = Playlist.Where(p => p.Beatmap.Value != null).Select(p => p.Beatmap.Value).OrderBy(b => b.StarRating).ToArray(); StarDifficulty minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0); StarDifficulty maxDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[^1].StarRating : 0, 0); diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 6deca0482a..57bb4253cb 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.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. -using System.Collections.Generic; -using System.Collections.Specialized; +using System; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; @@ -14,38 +12,136 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay { + /// + /// A scrollable list which displays the s in a . + /// public class DrawableRoomPlaylist : OsuRearrangeableListContainer { + /// + /// The currently-selected item. Selection is visually represented with a border. + /// May be updated by clicking playlist items if is true. + /// public readonly Bindable SelectedItem = new Bindable(); - private readonly bool allowEdit; - private readonly bool allowSelection; - private readonly bool showItemOwner; + /// + /// Invoked when an item is requested to be deleted. + /// + public Action RequestDeletion; - public DrawableRoomPlaylist(bool allowEdit, bool allowSelection, bool reverse = false, bool showItemOwner = false) + /// + /// Invoked when an item requests its results to be shown. + /// + public Action RequestResults; + + /// + /// Invoked when an item requests to be edited. + /// + public Action RequestEdit; + + private bool allowReordering; + + /// + /// Whether to allow reordering items in the playlist. + /// + public bool AllowReordering { - this.allowEdit = allowEdit; - this.allowSelection = allowSelection; - this.showItemOwner = showItemOwner; + get => allowReordering; + set + { + allowReordering = value; - ((ReversibleFillFlowContainer)ListContainer).Reverse = reverse; + foreach (var item in ListContainer.OfType()) + item.AllowReordering = value; + } } - protected override void LoadComplete() - { - base.LoadComplete(); + private bool allowDeletion; - // Scheduled since items are removed and re-added upon rearrangement - Items.CollectionChanged += (_, args) => Schedule(() => + /// + /// Whether to allow deleting items from the playlist. + /// If true, requests to delete items may be satisfied via . + /// + public bool AllowDeletion + { + get => allowDeletion; + set { - switch (args.Action) - { - case NotifyCollectionChangedAction.Remove: - if (allowSelection && args.OldItems.Contains(SelectedItem)) - SelectedItem.Value = null; - break; - } - }); + allowDeletion = value; + + foreach (var item in ListContainer.OfType()) + item.AllowDeletion = value; + } + } + + private bool allowSelection; + + /// + /// Whether to allow selecting items from the playlist. + /// If true, clicking on items in the playlist will change the value of . + /// + public bool AllowSelection + { + get => allowSelection; + set + { + allowSelection = value; + + foreach (var item in ListContainer.OfType()) + item.AllowSelection = value; + } + } + + private bool allowShowingResults; + + /// + /// Whether to allow items to request their results to be shown. + /// If true, requests to show the results may be satisfied via . + /// + public bool AllowShowingResults + { + get => allowShowingResults; + set + { + allowShowingResults = value; + + foreach (var item in ListContainer.OfType()) + item.AllowShowingResults = value; + } + } + + private bool allowEditing; + + /// + /// Whether to allow items to be edited. + /// If true, requests to edit items may be satisfied via . + /// + public bool AllowEditing + { + get => allowEditing; + set + { + allowEditing = value; + + foreach (var item in ListContainer.OfType()) + item.AllowEditing = value; + } + } + + private bool showItemOwners; + + /// + /// Whether to show the avatar of users which own each playlist item. + /// + public bool ShowItemOwners + { + get => showItemOwners; + set + { + showItemOwners = value; + + foreach (var item in ListContainer.OfType()) + item.ShowItemOwner = value; + } } protected override ScrollContainer CreateScrollContainer() => base.CreateScrollContainer().With(d => @@ -53,45 +149,25 @@ namespace osu.Game.Screens.OnlinePlay d.ScrollbarVisible = false; }); - protected override FillFlowContainer> CreateListFillFlowContainer() => new ReversibleFillFlowContainer + protected override FillFlowContainer> CreateListFillFlowContainer() => new FillFlowContainer> { Spacing = new Vector2(0, 2) }; - protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => new DrawableRoomPlaylistItem(item, allowEdit, allowSelection, showItemOwner) + protected sealed override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => CreateDrawablePlaylistItem(item).With(d => { - SelectedItem = { BindTarget = SelectedItem }, - RequestDeletion = requestDeletion - }; + d.SelectedItem.BindTarget = SelectedItem; + d.RequestDeletion = i => RequestDeletion?.Invoke(i); + d.RequestResults = i => RequestResults?.Invoke(i); + d.RequestEdit = i => RequestEdit?.Invoke(i); + d.AllowReordering = AllowReordering; + d.AllowDeletion = AllowDeletion; + d.AllowSelection = AllowSelection; + d.AllowShowingResults = AllowShowingResults; + d.AllowEditing = AllowEditing; + d.ShowItemOwner = ShowItemOwners; + }); - private void requestDeletion(PlaylistItem item) - { - if (allowSelection && SelectedItem.Value == item) - { - if (Items.Count == 1) - SelectedItem.Value = null; - else - SelectedItem.Value = Items.GetNext(item) ?? Items[^2]; - } - - Items.Remove(item); - } - - private class ReversibleFillFlowContainer : FillFlowContainer> - { - private bool reverse; - - public bool Reverse - { - get => reverse; - set - { - reverse = value; - Invalidate(); - } - } - - public override IEnumerable FlowingChildren => Reverse ? base.FlowingChildren.OrderBy(d => -GetLayoutPosition(d)) : base.FlowingChildren; - } + protected virtual DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new DrawableRoomPlaylistItem(item); } } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 2dbe2df82c..e1f7ea5e92 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -5,9 +5,9 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; 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; @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; 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.Database; @@ -24,6 +25,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays.BeatmapSet; using osu.Game.Rulesets; @@ -38,27 +40,51 @@ namespace osu.Game.Screens.OnlinePlay public class DrawableRoomPlaylistItem : OsuRearrangeableListItem { public const float HEIGHT = 50; - public const float ICON_HEIGHT = 34; + private const float icon_height = 34; + + /// + /// Invoked when this item requests to be deleted. + /// public Action RequestDeletion; + /// + /// Invoked when this item requests its results to be shown. + /// + public Action RequestResults; + + /// + /// Invoked when this item requests to be edited. + /// + public Action RequestEdit; + + /// + /// The currently-selected item, used to show a border around this item. + /// May be updated by this item if is true. + /// public readonly Bindable SelectedItem = new Bindable(); + public readonly PlaylistItem Item; + + private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both }; + private readonly IBindable valid = new Bindable(); + private readonly Bindable beatmap = new Bindable(); + private readonly Bindable ruleset = new Bindable(); + private readonly BindableList requiredMods = new BindableList(); + private Container maskingContainer; private Container difficultyIconContainer; private LinkFlowContainer beatmapText; private LinkFlowContainer authorText; private ExplicitContentBeatmapPill explicitContentPill; private ModDisplay modDisplay; + private FillFlowContainer buttonsFlow; private UpdateableAvatar ownerAvatar; - - private readonly IBindable valid = new Bindable(); - - private readonly Bindable beatmap = new Bindable(); - private readonly Bindable ruleset = new Bindable(); - private readonly BindableList requiredMods = new BindableList(); - - public readonly PlaylistItem Item; + private Drawable showResultsButton; + private Drawable editButton; + private Drawable removeButton; + private PanelBackground panelBackground; + private FillFlowContainer mainFillFlow; [Resolved] private OsuColour colours { get; set; } @@ -66,29 +92,25 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private UserLookupCache userLookupCache { get; set; } - private readonly bool allowEdit; - private readonly bool allowSelection; - private readonly bool showItemOwner; + [CanBeNull] + [Resolved(CanBeNull = true)] + private MultiplayerClient multiplayerClient { get; set; } - protected override bool ShouldBeConsideredForInput(Drawable child) => allowEdit || !allowSelection || SelectedItem.Value == Model; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } - public DrawableRoomPlaylistItem(PlaylistItem item, bool allowEdit, bool allowSelection, bool showItemOwner) + protected override bool ShouldBeConsideredForInput(Drawable child) => AllowReordering || AllowDeletion || !AllowSelection || SelectedItem.Value == Model; + + public DrawableRoomPlaylistItem(PlaylistItem item) : base(item) { Item = item; - // TODO: edit support should be moved out into a derived class - this.allowEdit = allowEdit; - this.allowSelection = allowSelection; - this.showItemOwner = showItemOwner; - beatmap.BindTo(item.Beatmap); valid.BindTo(item.Valid); ruleset.BindTo(item.Ruleset); requiredMods.BindTo(item.RequiredMods); - ShowDragHandle.Value = allowEdit; - if (item.Expired) Colour = OsuColour.Gray(0.5f); } @@ -96,9 +118,6 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { - if (!allowEdit) - HandleColour = HandleColour.Opacity(0); - maskingContainer.BorderColour = colours.Yellow; } @@ -130,10 +149,123 @@ namespace osu.Game.Screens.OnlinePlay valid.BindValueChanged(_ => Scheduler.AddOnce(refresh)); requiredMods.CollectionChanged += (_, __) => Scheduler.AddOnce(refresh); + onScreenLoader.DelayedLoadStarted += _ => + { + Task.Run(async () => + { + try + { + if (showItemOwner) + { + var foundUser = await userLookupCache.GetUserAsync(Item.OwnerID).ConfigureAwait(false); + Schedule(() => ownerAvatar.User = foundUser); + } + + if (Item.Beatmap.Value == null) + { + IBeatmapInfo foundBeatmap; + + if (multiplayerClient != null) + // This call can eventually go away (and use the else case below). + // Currently required only due to the method being overridden to provide special behaviour in tests. + foundBeatmap = await multiplayerClient.GetAPIBeatmap(Item.BeatmapID).ConfigureAwait(false); + else + foundBeatmap = await beatmapLookupCache.GetBeatmapAsync(Item.BeatmapID).ConfigureAwait(false); + + Schedule(() => Item.Beatmap.Value = foundBeatmap); + } + } + catch (Exception e) + { + Logger.Log($"Error while populating playlist item {e}"); + } + }); + }; + refresh(); } - private PanelBackground panelBackground; + /// + /// Whether this item can be selected. + /// + public bool AllowSelection { get; set; } + + /// + /// Whether this item can be reordered in the playlist. + /// + public bool AllowReordering + { + get => ShowDragHandle.Value; + set => ShowDragHandle.Value = value; + } + + private bool allowDeletion; + + /// + /// Whether this item can be deleted. + /// + public bool AllowDeletion + { + get => allowDeletion; + set + { + allowDeletion = value; + + if (removeButton != null) + removeButton.Alpha = value ? 1 : 0; + } + } + + private bool allowShowingResults; + + /// + /// Whether this item can have results shown. + /// + public bool AllowShowingResults + { + get => allowShowingResults; + set + { + allowShowingResults = value; + + if (showResultsButton != null) + showResultsButton.Alpha = value ? 1 : 0; + } + } + + private bool allowEditing; + + /// + /// Whether this item can be edited. + /// + public bool AllowEditing + { + get => allowEditing; + set + { + allowEditing = value; + + if (editButton != null) + editButton.Alpha = value ? 1 : 0; + } + } + + private bool showItemOwner; + + /// + /// Whether to display the avatar of the user which owns this playlist item. + /// + public bool ShowItemOwner + { + get => showItemOwner; + set + { + showItemOwner = value; + + if (ownerAvatar != null) + ownerAvatar.Alpha = value ? 1 : 0; + } + } private void refresh() { @@ -143,22 +275,22 @@ namespace osu.Game.Screens.OnlinePlay maskingContainer.BorderColour = colours.Red; } - if (showItemOwner) - { - ownerAvatar.Show(); - userLookupCache.GetUserAsync(Item.OwnerID) - .ContinueWith(u => Schedule(() => ownerAvatar.User = u.Result), TaskContinuationOptions.OnlyOnRanToCompletion); - } - - difficultyIconContainer.Child = new DifficultyIcon(Item.Beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(ICON_HEIGHT) }; + if (Item.Beatmap.Value != null) + difficultyIconContainer.Child = new DifficultyIcon(Item.Beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(icon_height) }; + else + difficultyIconContainer.Clear(); panelBackground.Beatmap.Value = Item.Beatmap.Value; beatmapText.Clear(); - beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineID.ToString(), null, text => + + if (Item.Beatmap.Value != null) { - text.Truncate = true; - }); + beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineID.ToString(), null, text => + { + text.Truncate = true; + }); + } authorText.Clear(); @@ -168,10 +300,16 @@ namespace osu.Game.Screens.OnlinePlay authorText.AddUserLink(Item.Beatmap.Value.Metadata.Author); } - bool hasExplicitContent = (Item.Beatmap.Value.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true; + bool hasExplicitContent = (Item.Beatmap.Value?.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true; explicitContentPill.Alpha = hasExplicitContent ? 1 : 0; modDisplay.Current.Value = requiredMods.ToArray(); + + buttonsFlow.Clear(); + buttonsFlow.ChildrenEnumerable = createButtons(); + + difficultyIconContainer.FadeInFromZero(500, Easing.OutQuint); + mainFillFlow.FadeInFromZero(500, Easing.OutQuint); } protected override Drawable CreateContent() @@ -192,6 +330,7 @@ namespace osu.Game.Screens.OnlinePlay Alpha = 0, AlwaysPresent = true }, + onScreenLoader, panelBackground = new PanelBackground { RelativeSizeAxes = Axes.Both, @@ -217,7 +356,7 @@ namespace osu.Game.Screens.OnlinePlay AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Left = 8, Right = 8 }, }, - new FillFlowContainer + mainFillFlow = new FillFlowContainer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -273,7 +412,7 @@ namespace osu.Game.Screens.OnlinePlay } } }, - new FillFlowContainer + buttonsFlow = new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -281,7 +420,7 @@ namespace osu.Game.Screens.OnlinePlay Margin = new MarginPadding { Horizontal = 8 }, AutoSizeAxes = Axes.Both, Spacing = new Vector2(5), - ChildrenEnumerable = CreateButtons().Select(button => button.With(b => + ChildrenEnumerable = createButtons().Select(button => button.With(b => { b.Anchor = Anchor.Centre; b.Origin = Anchor.Centre; @@ -291,11 +430,11 @@ namespace osu.Game.Screens.OnlinePlay { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(ICON_HEIGHT), + Size = new Vector2(icon_height), Margin = new MarginPadding { Right = 8 }, Masking = true, CornerRadius = 4, - Alpha = showItemOwner ? 1 : 0 + Alpha = ShowItemOwner ? 1 : 0 }, } } @@ -304,38 +443,53 @@ namespace osu.Game.Screens.OnlinePlay }; } - protected virtual IEnumerable CreateButtons() => - new Drawable[] + private IEnumerable createButtons() => new[] + { + showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie) { - new PlaylistDownloadButton(Item), - new PlaylistRemoveButton - { - Size = new Vector2(30, 30), - Alpha = allowEdit ? 1 : 0, - Action = () => RequestDeletion?.Invoke(Model), - }, - }; + Size = new Vector2(30, 30), + Action = () => RequestResults?.Invoke(Item), + Alpha = AllowShowingResults ? 1 : 0, + TooltipText = "View results" + }, + Item.Beatmap.Value == null ? Empty() : new PlaylistDownloadButton(Item), + editButton = new PlaylistEditButton + { + Size = new Vector2(30, 30), + Alpha = AllowEditing ? 1 : 0, + Action = () => RequestEdit?.Invoke(Item), + TooltipText = "Edit" + }, + removeButton = new PlaylistRemoveButton + { + Size = new Vector2(30, 30), + Alpha = AllowDeletion ? 1 : 0, + Action = () => RequestDeletion?.Invoke(Item), + TooltipText = "Remove from playlist" + }, + }; + + protected override bool OnClick(ClickEvent e) + { + if (AllowSelection && valid.Value) + SelectedItem.Value = Model; + return true; + } + + public class PlaylistEditButton : GrayButton + { + public PlaylistEditButton() + : base(FontAwesome.Solid.Edit) + { + } + } public class PlaylistRemoveButton : GrayButton { public PlaylistRemoveButton() : base(FontAwesome.Solid.MinusSquare) { - TooltipText = "Remove from playlist"; } - - [BackgroundDependencyLoader] - private void load() - { - Icon.Scale = new Vector2(0.8f); - } - } - - protected override bool OnClick(ClickEvent e) - { - if (allowSelection && valid.Value) - SelectedItem.Value = Model; - return true; } private sealed class PlaylistDownloadButton : BeatmapDownloadButton diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistWithResults.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistWithResults.cs deleted file mode 100644 index 8b1bb7abc1..0000000000 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistWithResults.cs +++ /dev/null @@ -1,69 +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.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay -{ - public class DrawableRoomPlaylistWithResults : DrawableRoomPlaylist - { - public Action RequestShowResults; - - private readonly bool showItemOwner; - - public DrawableRoomPlaylistWithResults(bool showItemOwner = false) - : base(false, true, showItemOwner: showItemOwner) - { - this.showItemOwner = showItemOwner; - } - - protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => - new DrawableRoomPlaylistItemWithResults(item, false, true, showItemOwner) - { - RequestShowResults = () => RequestShowResults(item), - SelectedItem = { BindTarget = SelectedItem }, - }; - - private class DrawableRoomPlaylistItemWithResults : DrawableRoomPlaylistItem - { - public Action RequestShowResults; - - public DrawableRoomPlaylistItemWithResults(PlaylistItem item, bool allowEdit, bool allowSelection, bool showItemOwner) - : base(item, allowEdit, allowSelection, showItemOwner) - { - } - - protected override IEnumerable CreateButtons() => - base.CreateButtons().Prepend(new FilledIconButton - { - Icon = FontAwesome.Solid.ChartPie, - Action = () => RequestShowResults?.Invoke(), - TooltipText = "View results" - }); - - private class FilledIconButton : IconButton - { - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Add(new Box - { - RelativeSizeAxes = Axes.Both, - Depth = float.MaxValue, - Colour = colours.Gray4, - }); - } - } - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 9920883078..a87f21630c 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -30,9 +31,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public readonly Room Room; - [Resolved] - private BeatmapManager beatmaps { get; set; } - protected Container ButtonsContainer { get; private set; } private readonly Bindable roomType = new Bindable(); @@ -187,20 +185,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5), - Children = new Drawable[] - { - new PlaylistCountPill - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new StarRatingRangeDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.8f) - } - } + ChildrenEnumerable = CreateBottomDetails() } } }, @@ -290,6 +275,37 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite(); + protected virtual IEnumerable CreateBottomDetails() + { + var pills = new List(); + + if (Room.Type.Value != MatchType.Playlists) + { + pills.AddRange(new OnlinePlayComposite[] + { + new MatchTypePill(), + new QueueModePill(), + }); + } + + pills.AddRange(new Drawable[] + { + new PlaylistCountPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new StarRatingRangeDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.8f) + } + }); + + return pills; + } + private class RoomNameText : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs new file mode 100644 index 0000000000..d104ede8f7 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class MatchTypePill : OnlinePlayComposite + { + private OsuTextFlowContainer textFlow; + + public MatchTypePill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PillContainer + { + Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Type.BindValueChanged(onMatchTypeChanged, true); + } + + private void onMatchTypeChanged(ValueChangedEvent type) + { + textFlow.Clear(); + textFlow.AddText(type.NewValue.GetLocalisableDescription()); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs new file mode 100644 index 0000000000..7501f0237b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class QueueModePill : OnlinePlayComposite + { + private OsuTextFlowContainer textFlow; + + public QueueModePill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PillContainer + { + Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + QueueMode.BindValueChanged(onQueueModeChanged, true); + } + + private void onQueueModeChanged(ValueChangedEvent mode) + { + textFlow.Clear(); + textFlow.AddText(mode.NewValue.GetLocalisableDescription()); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 54c762b8ce..f4d7823fcc 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -33,9 +33,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components [Resolved] private IRoomManager roomManager { get; set; } - [Resolved(CanBeNull = true)] - private LoungeSubScreen loungeSubScreen { get; set; } - // handle deselection public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index 0d2b2249ef..3fd56ece58 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -193,7 +193,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge this.room = room; } - private OsuPasswordTextBox passwordTextbox; + private OsuPasswordTextBox passwordTextBox; private TriangleButton joinButton; private OsuSpriteText errorText; private Sample sampleJoinFail; @@ -218,7 +218,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge AutoSizeAxes = Axes.Both, Children = new Drawable[] { - passwordTextbox = new OsuPasswordTextBox + passwordTextBox = new OsuPasswordTextBox { Width = 200, PlaceholderText = "password", @@ -246,21 +246,21 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { base.LoadComplete(); - Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); - passwordTextbox.OnCommit += (_, __) => performJoin(); + Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextBox)); + passwordTextBox.OnCommit += (_, __) => performJoin(); } private void performJoin() { - lounge?.Join(room, passwordTextbox.Text, null, joinFailed); - GetContainingInputManager().TriggerFocusContention(passwordTextbox); + lounge?.Join(room, passwordTextBox.Text, null, joinFailed); + GetContainingInputManager().TriggerFocusContention(passwordTextBox); } private void joinFailed(string error) => Schedule(() => { - passwordTextbox.Text = string.Empty; + passwordTextBox.Text = string.Empty; - GetContainingInputManager().ChangeFocus(passwordTextbox); + GetContainingInputManager().ChangeFocus(passwordTextBox); errorText.Text = error; errorText diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs index 6c00ca2e81..926c35c5da 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs @@ -3,7 +3,6 @@ #nullable enable -using System.Linq; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Online.Rooms; @@ -20,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public LoungeBackgroundScreen() { SelectedRoom.BindValueChanged(onSelectedRoomChanged); - playlist.BindCollectionChanged((_, __) => PlaylistItem = playlist.FirstOrDefault()); + playlist.BindCollectionChanged((_, __) => PlaylistItem = playlist.GetCurrentItem()); } private void onSelectedRoomChanged(ValueChangedEvent room) diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 7c5ed3f5cc..2d5225639f 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -21,6 +21,7 @@ using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Match { @@ -28,7 +29,7 @@ namespace osu.Game.Screens.OnlinePlay.Match public abstract class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { [Cached(typeof(IBindable))] - protected readonly Bindable SelectedItem = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); public override bool? AllowTrackAdjustments => true; @@ -67,7 +68,7 @@ namespace osu.Game.Screens.OnlinePlay.Match protected OnlinePlayScreen ParentScreen { get; private set; } [Cached] - private OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker { get; set; } + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); protected IBindable BeatmapAvailability => beatmapAvailabilityTracker.Availability; @@ -90,11 +91,6 @@ namespace osu.Game.Screens.OnlinePlay.Match Padding = new MarginPadding { Top = Header.HEIGHT }; - beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker - { - SelectedItem = { BindTarget = SelectedItem } - }; - RoomId.BindTo(room.RoomID); } @@ -106,6 +102,7 @@ namespace osu.Game.Screens.OnlinePlay.Match InternalChildren = new Drawable[] { beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), new GridContainer { RelativeSizeAxes = Axes.Both, @@ -247,10 +244,10 @@ namespace osu.Game.Screens.OnlinePlay.Match }, true); SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); - - beatmapManager.ItemUpdated += beatmapUpdated; - UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods)); + + beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem); + beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap()); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -305,6 +302,7 @@ namespace osu.Game.Screens.OnlinePlay.Match updateWorkingBeatmap(); beginHandlingTrack(); Scheduler.AddOnce(UpdateMods); + Scheduler.AddOnce(updateRuleset); } public override bool OnExiting(IScreen next) @@ -319,6 +317,16 @@ namespace osu.Game.Screens.OnlinePlay.Match protected void StartPlay() { + // User may be at song select or otherwise when the host starts gameplay. + // Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state. + if (!this.IsCurrentScreen()) + { + this.MakeCurrent(); + + Schedule(StartPlay); + return; + } + sampleStart?.Play(); // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). @@ -348,8 +356,7 @@ namespace osu.Game.Screens.OnlinePlay.Match .ToList(); UpdateMods(); - - Ruleset.Value = rulesets.GetRuleset(selected.RulesetID); + updateRuleset(); if (!selected.AllowedMods.Any()) { @@ -364,8 +371,6 @@ namespace osu.Game.Screens.OnlinePlay.Match } } - private void beatmapUpdated(BeatmapSetInfo set) => Schedule(updateWorkingBeatmap); - private void updateWorkingBeatmap() { var beatmap = SelectedItem.Value?.Beatmap.Value; @@ -378,12 +383,20 @@ namespace osu.Game.Screens.OnlinePlay.Match protected virtual void UpdateMods() { - if (SelectedItem.Value == null) + if (SelectedItem.Value == null || !this.IsCurrentScreen()) return; Mods.Value = UserMods.Value.Concat(SelectedItem.Value.RequiredMods).ToList(); } + private void updateRuleset() + { + if (SelectedItem.Value == null || !this.IsCurrentScreen()) + return; + + Ruleset.Value = rulesets.GetRuleset(SelectedItem.Value.RulesetID); + } + private void beginHandlingTrack() { Beatmap.BindValueChanged(applyLoopingToTrack, true); @@ -433,14 +446,6 @@ namespace osu.Game.Screens.OnlinePlay.Match /// The room to change the settings of. protected abstract RoomSettingsOverlay CreateRoomSettingsOverlay(Room room); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmapManager != null) - beatmapManager.ItemUpdated -= beatmapUpdated; - } - public class UserModSelectButton : PurpleTriangleButton { } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index 0e73f65f8b..53fd111c27 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public Bindable Expanded = new Bindable(); - private readonly Bindable expandedFromTextboxFocus = new Bindable(); + private readonly Bindable expandedFromTextBoxFocus = new Bindable(); private const float height = 100; @@ -37,7 +37,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Background.Alpha = 0.2f; - Textbox.FocusLost = () => expandedFromTextboxFocus.Value = false; + TextBox.FocusLost = () => expandedFromTextBoxFocus.Value = false; } protected override bool OnHover(HoverEvent e) => true; // use UI mouse cursor. @@ -51,14 +51,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { // for now let's never hold focus. this avoid misdirected gameplay keys entering chat. // note that this is done within this callback as it triggers an un-focus as well. - Textbox.HoldFocus = false; + TextBox.HoldFocus = false; // only hold focus (after sending a message) during breaks - Textbox.ReleaseFocusOnCommit = playing.NewValue; + TextBox.ReleaseFocusOnCommit = playing.NewValue; }, true); Expanded.BindValueChanged(_ => updateExpandedState(), true); - expandedFromTextboxFocus.BindValueChanged(focus => + expandedFromTextBoxFocus.BindValueChanged(focus => { if (focus.NewValue) updateExpandedState(); @@ -76,25 +76,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer switch (e.Action) { case GlobalAction.Back: - if (Textbox.HasFocus) + if (TextBox.HasFocus) { - Schedule(() => Textbox.KillFocus()); + Schedule(() => TextBox.KillFocus()); return true; } break; case GlobalAction.ToggleChatFocus: - if (Textbox.HasFocus) + if (TextBox.HasFocus) { - Schedule(() => Textbox.KillFocus()); + Schedule(() => TextBox.KillFocus()); } else { - expandedFromTextboxFocus.Value = true; + expandedFromTextBoxFocus.Value = true; // schedule required to ensure the textbox has become present from above bindable update. - Schedule(() => Textbox.TakeFocus()); + Schedule(() => TextBox.TakeFocus()); } return true; @@ -109,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void updateExpandedState() { - if (Expanded.Value || expandedFromTextboxFocus.Value) + if (Expanded.Value || expandedFromTextBoxFocus.Value) { this.FadeIn(300, Easing.OutQuint); this.ResizeHeightTo(height, 500, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 34edc1ccd1..7f1db733b3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -20,7 +19,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; -using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Match.Components; using osuTK; @@ -84,12 +82,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerClient client { get; set; } - [Resolved] - private Bindable beatmap { get; set; } - - [Resolved] - private Bindable ruleset { get; set; } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } @@ -256,7 +248,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Spacing = new Vector2(5), Children = new Drawable[] { - drawablePlaylist = new DrawableRoomPlaylist(false, false) + drawablePlaylist = new DrawableRoomPlaylist { RelativeSizeAxes = Axes.X, Height = DrawableRoomPlaylistItem.HEIGHT diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index ce988e377f..06959d942f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; -using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Screens.OnlinePlay.Components; using osuTK; @@ -25,9 +24,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match set => button.Action = value; } - [Resolved] - private IAPIProvider api { get; set; } - [Resolved] private OsuColour colours { get; set; } @@ -67,6 +63,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready"); } + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(_ => updateState()); + } + protected override void OnRoomUpdated() { base.OnRoomUpdated(); @@ -108,7 +111,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match bool enableButton = Room?.State == MultiplayerRoomState.Open - && Client.CurrentMatchPlayingItem.Value?.Expired == false + && SelectedItem.Value?.ID == Room.Settings.PlaylistItemId + && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired && !operationInProgress.Value; // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs new file mode 100644 index 0000000000..32d355d149 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.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.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// A historically-ordered list of s. + /// + public class MultiplayerHistoryList : DrawableRoomPlaylist + { + public MultiplayerHistoryList() + { + ShowItemOwners = true; + } + + protected override FillFlowContainer> CreateListFillFlowContainer() => new HistoryFillFlowContainer + { + Spacing = new Vector2(0, 2) + }; + + private class HistoryFillFlowContainer : FillFlowContainer> + { + public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderByDescending(item => item.Model.PlayedAt); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs new file mode 100644 index 0000000000..7b90532cce --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -0,0 +1,142 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// The multiplayer playlist, containing lists to show the items from a in both gameplay-order and historical-order. + /// + public class MultiplayerPlaylist : MultiplayerRoomComposite + { + public readonly Bindable DisplayMode = new Bindable(); + + /// + /// Invoked when an item requests to be edited. + /// + public Action RequestEdit; + + private MultiplayerQueueList queueList; + private MultiplayerHistoryList historyList; + private bool firstPopulation = true; + + [BackgroundDependencyLoader] + private void load() + { + const float tab_control_height = 25; + + InternalChildren = new Drawable[] + { + new OsuTabControl + { + RelativeSizeAxes = Axes.X, + Height = tab_control_height, + Current = { BindTarget = DisplayMode } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = tab_control_height + 5 }, + Masking = true, + Children = new Drawable[] + { + queueList = new MultiplayerQueueList + { + RelativeSizeAxes = Axes.Both, + SelectedItem = { BindTarget = SelectedItem }, + RequestEdit = item => RequestEdit?.Invoke(item) + }, + historyList = new MultiplayerHistoryList + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + SelectedItem = { BindTarget = SelectedItem } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + DisplayMode.BindValueChanged(onDisplayModeChanged, true); + } + + private void onDisplayModeChanged(ValueChangedEvent mode) + { + historyList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.History ? 1 : 0, 100); + queueList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.Queue ? 1 : 0, 100); + } + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + + if (Room == null) + { + historyList.Items.Clear(); + queueList.Items.Clear(); + firstPopulation = true; + return; + } + + if (firstPopulation) + { + foreach (var item in Room.Playlist) + addItemToLists(item); + + firstPopulation = false; + } + } + + protected override void PlaylistItemAdded(MultiplayerPlaylistItem item) + { + base.PlaylistItemAdded(item); + addItemToLists(item); + } + + protected override void PlaylistItemRemoved(long item) + { + base.PlaylistItemRemoved(item); + removeItemFromLists(item); + } + + protected override void PlaylistItemChanged(MultiplayerPlaylistItem item) + { + base.PlaylistItemChanged(item); + + removeItemFromLists(item.ID); + addItemToLists(item); + } + + private void addItemToLists(MultiplayerPlaylistItem item) + { + var apiItem = Playlist.SingleOrDefault(i => i.ID == item.ID); + + // Item could have been removed from the playlist while the local player was in gameplay. + if (apiItem == null) + return; + + if (item.Expired) + historyList.Items.Add(apiItem); + else + queueList.Items.Add(apiItem); + } + + private void removeItemFromLists(long item) + { + queueList.Items.RemoveAll(i => i.ID == item); + historyList.Items.RemoveAll(i => i.ID == item); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs new file mode 100644 index 0000000000..cc3dca6a34 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs @@ -0,0 +1,14 @@ +// 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.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// The type of list displayed in a . + /// + public enum MultiplayerPlaylistDisplayMode + { + Queue, + History, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs new file mode 100644 index 0000000000..1653d416d8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// A gameplay-ordered list of s. + /// + public class MultiplayerQueueList : DrawableRoomPlaylist + { + public MultiplayerQueueList() + { + ShowItemOwners = true; + } + + protected override FillFlowContainer> CreateListFillFlowContainer() => new QueueFillFlowContainer + { + Spacing = new Vector2(0, 2) + }; + + protected override DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new QueuePlaylistItem(item); + + private class QueueFillFlowContainer : FillFlowContainer> + { + [Resolved(typeof(Room), nameof(Room.Playlist))] + private BindableList roomPlaylist { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + roomPlaylist.BindCollectionChanged((_, __) => InvalidateLayout()); + } + + public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderBy(item => item.Model.PlaylistOrder); + } + + private class QueuePlaylistItem : DrawableRoomPlaylistItem + { + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } + + public QueuePlaylistItem(PlaylistItem item) + : base(item) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID); + + multiplayerClient.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(updateDeleteButtonVisibility); + + private void updateDeleteButtonVisibility() + { + if (multiplayerClient.Room == null) + return; + + bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost; + + AllowDeletion = isItemOwner && !Item.Expired && Item.ID != multiplayerClient.Room.Settings.PlaylistItemId; + AllowEditing = isItemOwner && !Item.Expired; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (multiplayerClient != null) + multiplayerClient.RoomUpdated -= onRoomUpdated; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 58b5b7bbeb..28c9bef3f0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; @@ -14,11 +15,53 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() + { + if (client.Room == null) + return; + + Debug.Assert(client.LocalUser != null); + + // If the user exits gameplay before score submission completes, we'll transition to idle when results has been prepared. + if (client.LocalUser.State == MultiplayerUserState.Results && this.IsCurrentScreen()) + transitionFromResults(); + } + public override void OnResuming(IScreen last) { base.OnResuming(last); - if (client.Room != null && client.LocalUser?.State != MultiplayerUserState.Spectating) + if (client.Room == null) + return; + + if (!(last is MultiplayerPlayerLoader playerLoader)) + return; + + // If gameplay wasn't finished, then we have a simple path back to the idle state by aborting gameplay. + if (!playerLoader.GameplayPassed) + { + client.AbortGameplay(); + return; + } + + // If gameplay was completed and the user went all the way to results, we'll transition to idle here. + // Otherwise, the transition will happen in onRoomUpdated(). + transitionFromResults(); + } + + private void transitionFromResults() + { + Debug.Assert(client.LocalUser != null); + + if (client.LocalUser.State == MultiplayerUserState.Results) client.ChangeState(MultiplayerUserState.Idle); } @@ -27,5 +70,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override RoomManager CreateRoomManager() => new MultiplayerRoomManager(); protected override LoungeSubScreen CreateLounge() => new MultiplayerLoungeSubScreen(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client != null) + client.RoomUpdated -= onRoomUpdated; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 44efef53f5..073497e1ce 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using osu.Framework.Allocation; using osu.Framework.Logging; @@ -24,17 +25,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } + private readonly long? itemToEdit; + private LoadingLayer loadingLayer; /// /// Construct a new instance of multiplayer song select. /// /// 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, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) + public MultiplayerMatchSongSelect(Room room, long? itemToEdit = null, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) : base(room) { + this.itemToEdit = itemToEdit; + if (beatmap != null || ruleset != null) { Schedule(() => @@ -59,14 +65,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { loadingLayer.Show(); - client.AddPlaylistItem(new MultiplayerPlaylistItem + var multiplayerItem = new MultiplayerPlaylistItem { + ID = itemToEdit ?? 0, BeatmapID = item.BeatmapID, BeatmapChecksum = item.Beatmap.Value.MD5Hash, RulesetID = item.RulesetID, RequiredMods = item.RequiredMods.Select(m => new APIMod(m)).ToArray(), AllowedMods = item.AllowedMods.Select(m => new APIMod(m)).ToArray() - }).ContinueWith(t => + }; + + Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem); + + task.ContinueWith(t => { Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 077e9cef93..4bd68f2034 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -5,16 +5,17 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using JetBrains.Annotations; 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.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -26,9 +27,9 @@ using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osuTK; @@ -43,8 +44,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public override string ShortTitle => "room"; - public OsuButton AddOrEditPlaylistButton { get; private set; } - [Resolved] private MultiplayerClient client { get; set; } @@ -56,7 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [CanBeNull] private IDisposable readyClickOperation; - private DrawableRoomPlaylist playlist; + private AddItemButton addItemButton; public MultiplayerMatchSubScreen(Room room) : base(room) @@ -69,14 +68,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadComplete(); - SelectedItem.BindTo(client.CurrentMatchPlayingItem); - BeatmapAvailability.BindValueChanged(updateBeatmapAvailability, true); UserMods.BindValueChanged(onUserModsChanged); - playlist.Items.BindTo(Room.Playlist); - playlist.SelectedItem.BindTo(SelectedItem); - client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; @@ -138,25 +132,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer new Drawable[] { new OverlinedHeader("Beatmap") }, new Drawable[] { - AddOrEditPlaylistButton = new PurpleTriangleButton + addItemButton = new AddItemButton { RelativeSizeAxes = Axes.X, Height = 40, - Action = () => - { - if (this.IsCurrentScreen()) - this.Push(new MultiplayerMatchSongSelect(Room)); - }, - Alpha = 0 + Text = "Add item", + Action = () => OpenSongSelection() }, }, null, new Drawable[] { - playlist = new DrawableRoomPlaylist(false, false, true, true) + new MultiplayerPlaylist { RelativeSizeAxes = Axes.Both, - }, + RequestEdit = item => OpenSongSelection(item.ID) + } }, new[] { @@ -228,6 +219,18 @@ 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) + { + if (!this.IsCurrentScreen()) + return; + + this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); + } + protected override Drawable CreateFooter() => new MultiplayerMatchFooter { OnReadyClick = onReadyClick, @@ -238,7 +241,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override void UpdateMods() { - if (SelectedItem.Value == null || client.LocalUser == null) + if (SelectedItem.Value == null || client.LocalUser == null || !this.IsCurrentScreen()) return; // update local mods based on room's reported status for the local user (omitting the base call implementation). @@ -323,10 +326,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.LocalUser?.State == MultiplayerUserState.Ready) client.ChangeState(MultiplayerUserState.Idle); } - else + else if (client.LocalUser?.State == MultiplayerUserState.Spectating + && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) { - if (client.LocalUser?.State == MultiplayerUserState.Spectating && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) - onLoadRequested(); + onLoadRequested(); } } @@ -385,29 +388,54 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - switch (client.Room.Settings.QueueMode) - { - case QueueMode.HostOnly: - AddOrEditPlaylistButton.Text = "Edit beatmap"; - AddOrEditPlaylistButton.Alpha = client.IsHost ? 1 : 0; - break; + updateCurrentItem(); - case QueueMode.AllPlayers: - case QueueMode.AllPlayersRoundRobin: - AddOrEditPlaylistButton.Text = "Add beatmap"; - AddOrEditPlaylistButton.Alpha = 1; - break; - - default: - AddOrEditPlaylistButton.Alpha = 0; - break; - } + addItemButton.Alpha = client.IsHost || Room.QueueMode.Value != QueueMode.HostOnly ? 1 : 0; Scheduler.AddOnce(UpdateMods); } + private void updateCurrentItem() + { + Debug.Assert(client.Room != null); + + var expectedSelectedItem = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId); + + if (expectedSelectedItem == null) + return; + + // There's no reason to renew the selected item if its content hasn't changed. + if (SelectedItem.Value?.Equals(expectedSelectedItem) == true && expectedSelectedItem.Beatmap.Value != null) + return; + + // Clear the selected item while the lookup is performed, so components like the ready button can enter their disabled states. + SelectedItem.Value = null; + + if (expectedSelectedItem.Beatmap.Value == null) + { + Task.Run(async () => + { + var beatmap = await client.GetAPIBeatmap(expectedSelectedItem.BeatmapID).ConfigureAwait(false); + + Schedule(() => + { + expectedSelectedItem.Beatmap.Value = beatmap; + + if (Room.Playlist.SingleOrDefault(i => i.ID == client.Room?.Settings.PlaylistItemId)?.Equals(expectedSelectedItem) == true) + applyCurrentItem(); + }); + }); + } + else + applyCurrentItem(); + + void applyCurrentItem() => SelectedItem.Value = expectedSelectedItem; + } + private void handleRoomLost() => Schedule(() => { + Logger.Log($"{this} exiting due to loss of room or connection"); + if (this.IsCurrentScreen()) this.Exit(); else @@ -449,7 +477,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return new MultiSpectatorScreen(users.Take(PlayerGrid.MAX_PLAYERS).ToArray()); default: - return new PlayerLoader(() => new MultiplayerPlayer(Room, SelectedItem.Value, users)); + return new MultiplayerPlayerLoader(() => new MultiplayerPlayer(Room, SelectedItem.Value, users)); } } @@ -458,6 +486,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!this.IsCurrentScreen()) return; + if (client.Room == null) + return; + if (!client.IsHost) { // todo: should handle this when the request queue is implemented. @@ -466,7 +497,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - this.Push(new MultiplayerMatchSongSelect(Room, beatmap, ruleset)); + this.Push(new MultiplayerMatchSongSelect(Room, client.Room.Settings.PlaylistItemId, beatmap, ruleset)); } protected override void Dispose(bool isDisposing) @@ -481,5 +512,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer modSettingChangeTracker?.Dispose(); } + + public class AddItemButton : PurpleTriangleButton + { + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs new file mode 100644 index 0000000000..470ba59a76 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.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 System; +using osu.Framework.Screens; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public class MultiplayerPlayerLoader : PlayerLoader + { + public bool GameplayPassed => player?.GameplayPassed == true; + + private Player player; + + public MultiplayerPlayerLoader(Func createPlayer) + : base(createPlayer) + { + } + + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + player = (Player)next; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index a380ddef25..7d2fe44c4e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -4,6 +4,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -23,14 +24,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Client.UserLeft += invokeUserLeft; Client.UserKicked += invokeUserKicked; Client.UserJoined += invokeUserJoined; + Client.ItemAdded += invokeItemAdded; + Client.ItemRemoved += invokeItemRemoved; + Client.ItemChanged += invokeItemChanged; OnRoomUpdated(); } private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated); - private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.AddOnce(UserJoined, user); - private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(UserKicked, user); - private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.AddOnce(UserLeft, user); + private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user)); + private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user)); + private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user)); + private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item)); + private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item)); + private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item)); /// /// Invoked when a user has joined the room. @@ -56,6 +63,30 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { } + /// + /// Invoked when a playlist item is added to the room. + /// + /// The added playlist item. + protected virtual void PlaylistItemAdded(MultiplayerPlaylistItem item) + { + } + + /// + /// Invoked when a playlist item is removed from the room. + /// + /// The ID of the removed playlist item. + protected virtual void PlaylistItemRemoved(long item) + { + } + + /// + /// Invoked when a playlist item is changed in the room. + /// + /// The new playlist item, with an existing item's ID. + protected virtual void PlaylistItemChanged(MultiplayerPlaylistItem item) + { + } + /// /// Invoked when any change occurs to the multiplayer room. /// @@ -71,6 +102,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Client.UserLeft -= invokeUserLeft; Client.UserKicked -= invokeUserKicked; Client.UserJoined -= invokeUserJoined; + Client.ItemAdded -= invokeItemAdded; + Client.ItemRemoved -= invokeItemRemoved; + Client.ItemChanged -= invokeItemChanged; } base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs new file mode 100644 index 0000000000..d467a32acb --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs @@ -0,0 +1,65 @@ +// 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.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public class MultiplayerRoomSounds : MultiplayerRoomComposite + { + private Sample hostChangedSample; + private Sample userJoinedSample; + private Sample userLeftSample; + private Sample userKickedSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + hostChangedSample = audio.Samples.Get(@"Multiplayer/host-changed"); + userJoinedSample = audio.Samples.Get(@"Multiplayer/player-joined"); + userLeftSample = audio.Samples.Get(@"Multiplayer/player-left"); + userKickedSample = audio.Samples.Get(@"Multiplayer/player-kicked"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Host.BindValueChanged(hostChanged); + } + + protected override void UserJoined(MultiplayerRoomUser user) + { + base.UserJoined(user); + + userJoinedSample?.Play(); + } + + protected override void UserLeft(MultiplayerRoomUser user) + { + base.UserLeft(user); + + userLeftSample?.Play(); + } + + protected override void UserKicked(MultiplayerRoomUser user) + { + base.UserKicked(user); + + userKickedSample?.Play(); + } + + private void hostChanged(ValueChangedEvent value) + { + // only play sound when the host changes from an already-existing host. + if (value.OldValue == null) return; + + hostChangedSample?.Play(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 3152f50d3d..8fbaebadfe 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -35,7 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private SpriteIcon crown; @@ -185,9 +185,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; // Todo: Should use the room's selected item to determine ruleset. - var ruleset = rulesets.GetRuleset(0).CreateInstance(); + var ruleset = rulesets.GetRuleset(0)?.CreateInstance(); - int? currentModeRank = User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank; + int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 2ad64e115e..fe40a4bfe6 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -4,12 +4,10 @@ using System.Linq; 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.Containers; using osu.Game.Graphics.Cursor; -using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants @@ -18,10 +16,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { private FillFlowContainer panels; - private Sample userJoinSample; - private Sample userLeftSample; - private Sample userKickedSample; - [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -41,31 +35,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } }; - - userJoinSample = audio.Samples.Get(@"Multiplayer/player-joined"); - userLeftSample = audio.Samples.Get(@"Multiplayer/player-left"); - userKickedSample = audio.Samples.Get(@"Multiplayer/player-kicked"); - } - - protected override void UserJoined(MultiplayerRoomUser user) - { - base.UserJoined(user); - - userJoinSample?.Play(); - } - - protected override void UserLeft(MultiplayerRoomUser user) - { - base.UserLeft(user); - - userLeftSample?.Play(); - } - - protected override void UserKicked(MultiplayerRoomUser user) - { - base.UserKicked(user); - - userKickedSample?.Play(); } protected override void OnRoomUpdated() @@ -77,7 +46,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants else { // Remove panels for users no longer in the room. - panels.RemoveAll(p => !Room.Users.Contains(p.User)); + foreach (var p in panels) + { + // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. + if (Room.Users.All(u => !ReferenceEquals(p.User, u))) + p.Expire(); + } // Add panels for all users new to the room. foreach (var user in Room.Users.Except(panels.Select(p => p.User))) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 57d0d2c198..7350408eba 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -36,9 +37,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [Resolved] private OsuColour colours { get; set; } - [Resolved] - private SpectatorClient spectatorClient { get; set; } - [Resolved] private MultiplayerClient multiplayerClient { get; set; } @@ -229,8 +227,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public override bool OnBackButton() { - // On a manual exit, set the player state back to idle. - multiplayerClient.ChangeState(MultiplayerUserState.Idle); + Debug.Assert(multiplayerClient.Room != null); + + // On a manual exit, set the player back to idle unless gameplay has finished. + if (multiplayerClient.Room.State != MultiplayerRoomState.Open) + multiplayerClient.ChangeState(MultiplayerUserState.Idle); + return base.OnBackButton(); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index c3190cd845..48f153ecbe 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -87,7 +87,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, - Child = stack = new OsuScreenStack() + Child = stack = new OsuScreenStack + { + Name = nameof(PlayerArea), + } }; stack.Push(new MultiSpectatorPlayerLoader(Score, () => new MultiSpectatorPlayer(Score, GameplayClock))); diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index 6b111d76a5..c833621fbc 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -73,7 +72,7 @@ namespace osu.Game.Screens.OnlinePlay private IBindable subScreenSelectedItem { get; set; } /// - /// The currently selected item in the , or the last item from + /// The currently selected item in the , or the current item from /// if this is not within a . /// protected readonly Bindable SelectedItem = new Bindable(); @@ -88,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay protected virtual void UpdateSelectedItem() => SelectedItem.Value = RoomID.Value == null || subScreenSelectedItem == null - ? Playlist.LastOrDefault() + ? Playlist.GetCurrentItem() : subScreenSelectedItem.Value; } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index a18e4b45cf..bf1699dca0 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -40,18 +40,9 @@ namespace osu.Game.Screens.OnlinePlay [Cached] private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); - [Resolved(CanBeNull = true)] - private MusicController music { get; set; } - - [Resolved] - private OsuGameBase game { get; set; } - [Resolved] protected IAPIProvider API { get; private set; } - [Resolved(CanBeNull = true)] - private OsuLogo logo { get; set; } - protected OnlinePlayScreen() { Anchor = Anchor.Centre; @@ -104,6 +95,8 @@ namespace osu.Game.Screens.OnlinePlay private void forcefullyExit() { + Logger.Log($"{this} forcefully exiting due to loss of API connection"); + // This is temporary since we don't currently have a way to force screens to be exited if (this.IsCurrentScreen()) { diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 4bc0b55433..63957caee3 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -33,14 +33,14 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room), nameof(Room.Playlist))] protected BindableList Playlist { get; private set; } + [CanBeNull] + [Resolved(CanBeNull = true)] + protected IBindable SelectedItem { get; private set; } + protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); - [CanBeNull] - [Resolved(CanBeNull = true)] - private IBindable selectedItem { get; set; } - private readonly FreeModSelectOverlay freeModSelectOverlay; private readonly Room room; @@ -80,8 +80,8 @@ namespace osu.Game.Screens.OnlinePlay // 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.DeepClone()).ToArray() ?? Array.Empty(); - FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); + Mods.Value = SelectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); + FreeMods.Value = SelectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); Mods.BindValueChanged(onModsChanged); Ruleset.BindValueChanged(onRulesetChanged); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index aed3635cbc..1e6722d51e 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -87,6 +87,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { var allScores = new List { userScore }; + // Other scores could have arrived between score submission and entering the results screen. Ensure the local player score position is up to date. + if (Score != null) + { + Score.Position = userScore.Position; + ScorePanelList.GetPanelForScore(Score).ScorePosition.Value = userScore.Position; + } + if (userScore.ScoresAround?.Higher != null) { allScores.AddRange(userScore.ScoresAround.Higher.Scores); @@ -186,12 +193,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Schedule(() => { // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + 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.OnlineScoreID != Score?.OnlineScoreID)); + callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); hideLoadingSpinners(pivot); })); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 27c8dc1120..6c8ab52d22 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Specialized; using System.Linq; using Humanizer; +using Humanizer.Localisation; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,6 +16,8 @@ using osu.Game.Graphics; 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.Responses; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -69,6 +73,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved(CanBeNull = true)] private IRoomManager manager { get; set; } + [Resolved] + private IAPIProvider api { get; set; } + private readonly Room room; public MatchSettings(Room room) @@ -134,19 +141,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Child = DurationField = new DurationDropdown { RelativeSizeAxes = Axes.X, - Items = new[] - { - TimeSpan.FromMinutes(30), - TimeSpan.FromHours(1), - TimeSpan.FromHours(2), - TimeSpan.FromHours(4), - TimeSpan.FromHours(8), - TimeSpan.FromHours(12), - //TimeSpan.FromHours(16), - TimeSpan.FromHours(24), - TimeSpan.FromDays(3), - TimeSpan.FromDays(7) - } } }, new Section("Allowed attempts (across all playlist items)") @@ -205,7 +199,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { new Drawable[] { - playlist = new DrawableRoomPlaylist(true, false) { RelativeSizeAxes = Axes.Both } + playlist = new PlaylistsRoomSettingsPlaylist + { + RelativeSizeAxes = Axes.Both, + } }, new Drawable[] { @@ -300,10 +297,40 @@ 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); + playlist.Items.BindTo(Playlist); Playlist.BindCollectionChanged(onPlaylistChanged, true); } + private void populateDurations(ValueChangedEvent user) + { + DurationField.Items = new[] + { + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(1), + TimeSpan.FromHours(2), + TimeSpan.FromHours(4), + TimeSpan.FromHours(8), + TimeSpan.FromHours(12), + TimeSpan.FromHours(24), + TimeSpan.FromDays(3), + TimeSpan.FromDays(7), + TimeSpan.FromDays(14), + }; + + // TODO: show these in the interface at all times. + if (user.NewValue.IsSupporter) + { + // roughly correct (see https://github.com/Humanizr/Humanizer/blob/18167e56c082449cc4fe805b8429e3127a7b7f93/readme.md?plain=1#L427) + // if we want this to be more accurate we might consider sending an actual end time, not a time span. probably not required though. + const int days_in_month = 31; + + DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month)); + DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month * 3)); + } + } + protected override void Update() { base.Update(); @@ -402,7 +429,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Menu.MaxHeight = 100; } - protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize(); + protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize(maxUnit: TimeUnit.Month); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs new file mode 100644 index 0000000000..2fe215eef2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.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 System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + /// + /// A which is displayed during the setup stage of a playlists room. + /// + public class PlaylistsRoomSettingsPlaylist : DrawableRoomPlaylist + { + public PlaylistsRoomSettingsPlaylist() + { + AllowReordering = true; + AllowDeletion = true; + + RequestDeletion = item => + { + var nextItem = Items.GetNext(item); + + Items.Remove(item); + + SelectedItem.Value = nextItem ?? Items.LastOrDefault(); + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 6d2a426e70..4114a5e9a0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Input; -using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; @@ -29,9 +28,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public override string ShortTitle => "playlist"; - [Resolved] - private IAPIProvider api { get; set; } - private readonly IBindable isIdle = new BindableBool(); private MatchLeaderboard leaderboard; @@ -92,12 +88,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Drawable[] { new OverlinedPlaylistHeader(), }, new Drawable[] { - new DrawableRoomPlaylistWithResults + new DrawableRoomPlaylist { RelativeSizeAxes = Axes.Both, Items = { BindTarget = Room.Playlist }, SelectedItem = { BindTarget = SelectedItem }, - RequestShowResults = item => + AllowSelection = true, + AllowShowingResults = true, + RequestResults = item => { Debug.Assert(RoomId.Value != null); ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 03c95ec060..0fd76f7e25 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -2,9 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.Select; @@ -13,9 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public class PlaylistsSongSelect : OnlinePlaySongSelect { - [Resolved] - private BeatmapManager beatmaps { get; set; } - public PlaylistsSongSelect(Room room) : base(room) { diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index ccc891d3bf..ed4901e1fa 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -145,7 +145,7 @@ namespace osu.Game.Screens } [BackgroundDependencyLoader(true)] - private void load(OsuGame osu, AudioManager audio) + private void load(AudioManager audio) { sampleExit = audio.Samples.Get(@"UI/screen-back"); } diff --git a/osu.Game/Screens/Play/EpilepsyWarning.cs b/osu.Game/Screens/Play/EpilepsyWarning.cs index 89e25d849f..ccb2870d78 100644 --- a/osu.Game/Screens/Play/EpilepsyWarning.cs +++ b/osu.Game/Screens/Play/EpilepsyWarning.cs @@ -2,11 +2,9 @@ // 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; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Screens.Backgrounds; @@ -39,7 +37,7 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader] - private void load(OsuColour colours, IBindable beatmap) + private void load(OsuColour colours) { Children = new Drawable[] { diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 193e1e4129..17a3e5eb71 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -6,6 +6,7 @@ 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; @@ -18,6 +19,7 @@ using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Graphics; @@ -34,6 +36,7 @@ namespace osu.Game.Screens.Play private readonly DrawableRuleset drawableRuleset; private readonly BindableDouble trackFreq = new BindableDouble(1); + private readonly BindableDouble volumeAdjustment = new BindableDouble(0.5); private Container filters; @@ -58,6 +61,12 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, }; + /// + /// The player screen background, used to adjust appearance on failing. + /// + [CanBeNull] + public BackgroundScreen Background { private get; set; } + public FailAnimation(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; @@ -117,6 +126,7 @@ namespace osu.Game.Screens.Play failSample.Play(); track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); + track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); applyToPlayfield(drawableRuleset.Playfield); drawableRuleset.Playfield.HitObjectContainer.FadeOut(duration / 2); @@ -136,6 +146,9 @@ namespace osu.Game.Screens.Play Content.ScaleTo(0.85f, duration, Easing.OutQuart); Content.RotateTo(1, duration, Easing.OutQuart); Content.FadeColour(Color4.Gray, duration); + + // Will be restored by `ApplyToBackground` logic in `SongSelect`. + Background?.FadeColour(OsuColour.Gray(0.3f), 60); } public void RemoveFilters(bool resetTrackFrequency = true) @@ -143,6 +156,8 @@ namespace osu.Game.Screens.Play if (resetTrackFrequency) track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + track?.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); + if (filters.Parent == null) return; diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index 324e5d43b5..06b53e8426 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -9,9 +9,6 @@ namespace osu.Game.Screens.Play.HUD { public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - public bool UsesFixedAnchor { get; set; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 6d87211ddc..52f86d2bc3 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -15,9 +15,6 @@ namespace osu.Game.Screens.Play.HUD { public class DefaultComboCounter : RollingCounter, ISkinnableDrawable { - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - public bool UsesFixedAnchor { get; set; } public DefaultComboCounter() diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 87b19e8433..6af89404e0 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -16,9 +16,6 @@ namespace osu.Game.Screens.Play.HUD Origin = Anchor.TopCentre; } - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - public bool UsesFixedAnchor { get; set; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 1f0fafa636..94f1b99b37 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Play.HUD /// public abstract class HealthDisplay : CompositeDrawable { - private readonly Bindable showHealthbar = new Bindable(true); + private readonly Bindable showHealthBar = new Bindable(true); [Resolved] protected HealthProcessor HealthProcessor { get; private set; } @@ -43,10 +43,10 @@ namespace osu.Game.Screens.Play.HUD HealthProcessor.NewJudgement += onNewJudgement; if (hudOverlay != null) - showHealthbar.BindTo(hudOverlay.ShowHealthbar); + showHealthBar.BindTo(hudOverlay.ShowHealthBar); // this probably shouldn't be operating on `this.` - showHealthbar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + showHealthBar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); } private void onNewJudgement(JudgementResult judgement) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index c7b06a3a2c..1f08cb8aa7 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -40,9 +40,16 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters if (gameplayClockContainer != null) gameplayClockContainer.OnSeek += Clear; - processor.NewJudgement += OnNewJudgement; + processor.NewJudgement += processorNewJudgement; } + // Scheduled as meter implementations are likely going to change/add drawables when reacting to this. + private void processorNewJudgement(JudgementResult j) => Schedule(() => OnNewJudgement(j)); + + /// + /// Fired when a new judgement arrives. + /// + /// The new judgement. protected abstract void OnNewJudgement(JudgementResult judgement); protected Color4 GetColourForHitResult(HitResult result) @@ -84,7 +91,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters base.Dispose(isDisposing); if (processor != null) - processor.NewJudgement -= OnNewJudgement; + processor.NewJudgement -= processorNewJudgement; if (gameplayClockContainer != null) gameplayClockContainer.OnSeek -= Clear; diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 8e0a38aa1f..5a7ef786d3 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -116,7 +116,7 @@ namespace osu.Game.Screens.Play.HUD public Action HoverLost; [BackgroundDependencyLoader] - private void load(OsuColour colours, Framework.Game game) + private void load(OsuColour colours) { Size = new Vector2(60); diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 5c5b66d496..567e4386c6 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -40,9 +40,6 @@ namespace osu.Game.Screens.Play.HUD private bool isRolling; - [Resolved] - private ISkinSource skin { get; set; } - private readonly Container counterContainer; /// @@ -70,22 +67,32 @@ namespace osu.Game.Screens.Play.HUD Scale = new Vector2(1.2f); - InternalChild = counterContainer = new Container + InternalChildren = new[] { - AutoSizeAxes = Axes.Both, - AlwaysPresent = true, - Children = new[] + counterContainer = new Container { - popOutCount = new LegacySpriteText(LegacyFont.Combo) + AutoSizeAxes = Axes.Both, + AlwaysPresent = true, + Children = new[] { - Alpha = 0, - Margin = new MarginPadding(0.05f), - Blending = BlendingParameters.Additive, - }, - displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo) - { - Alpha = 0, - }, + popOutCount = new LegacySpriteText(LegacyFont.Combo) + { + Alpha = 0, + Margin = new MarginPadding(0.05f), + Blending = BlendingParameters.Additive, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + BypassAutoSizeAxes = Axes.Both, + }, + displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo) + { + // Initial text and AlwaysPresent allow the counter to have a size before it first displays a combo. + // This is useful for display in the skin editor. + Text = formatCount(0), + AlwaysPresent = true, + Alpha = 0, + }, + } } }; } @@ -124,13 +131,6 @@ namespace osu.Game.Screens.Play.HUD ((IHasText)displayedCountSpriteText).Text = formatCount(Current.Value); - counterContainer.Anchor = Anchor; - counterContainer.Origin = Origin; - displayedCountSpriteText.Anchor = Anchor; - displayedCountSpriteText.Origin = Origin; - popOutCount.Anchor = Anchor; - popOutCount.Origin = Origin; - Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true); } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index e019ee9a3d..83c73e5a70 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -7,6 +7,7 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Configuration; using osu.Game.Database; @@ -76,9 +77,11 @@ namespace osu.Game.Screens.Play.HUD TeamScores.Add(team, new BindableInt()); } - userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray()).ContinueWith(users => Schedule(() => + userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray()).ContinueWith(task => Schedule(() => { - foreach (var user in users.Result) + var users = task.GetResultSafely(); + + foreach (var user in users) { if (user == null) continue; diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs index 854f255269..21a7698248 100644 --- a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio.Track; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -73,10 +74,12 @@ namespace osu.Game.Screens.Play.HUD var gameplayWorkingBeatmap = new GameplayWorkingBeatmap(gameplayState.Beatmap); difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token) - .ContinueWith(r => Schedule(() => + .ContinueWith(task => Schedule(() => { - timedAttributes = r.Result; + timedAttributes = task.GetResultSafely(); + IsValid = true; + if (lastJudgement != null) onJudgementChanged(lastJudgement); }), TaskContinuationOptions.OnlyOnRanToCompletion); @@ -129,7 +132,7 @@ namespace osu.Game.Screens.Play.HUD var calculator = gameplayState.Ruleset.CreatePerformanceCalculator(attrib, scoreInfo); - Current.Value = (int)Math.Round(calculator?.Calculate() ?? 0, MidpointRounding.AwayFromZero); + Current.Value = (int)Math.Round(calculator?.Calculate().Total ?? 0, MidpointRounding.AwayFromZero); IsValid = true; } diff --git a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs index 235f0f01fd..a71b661965 100644 --- a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs +++ b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -37,7 +36,7 @@ namespace osu.Game.Screens.Play.HUD } [BackgroundDependencyLoader] - private void load(OsuColour colours, BeatmapDifficultyCache difficultyCache) + private void load(OsuColour colours) { Colour = colours.BlueLighter; valid.BindValueChanged(e => diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index b5c4433719..fdb5d418f3 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -46,7 +46,7 @@ namespace osu.Game.Screens.Play public readonly HoldForMenuButton HoldToQuit; public readonly PlayerSettingsOverlay PlayerSettingsOverlay; - public Bindable ShowHealthbar = new Bindable(true); + public Bindable ShowHealthBar = new Bindable(true); private readonly DrawableRuleset drawableRuleset; private readonly IReadOnlyList mods; @@ -258,7 +258,7 @@ namespace osu.Game.Screens.Play protected FailingLayer CreateFailingLayer() => new FailingLayer { - ShowHealth = { BindTarget = ShowHealthbar } + ShowHealth = { BindTarget = ShowHealthBar } }; protected KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 4d574dea99..2a6f5e2398 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -9,6 +9,7 @@ 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; @@ -66,6 +67,11 @@ namespace osu.Game.Screens.Play /// protected virtual bool PauseOnFocusLost => true; + /// + /// Whether gameplay has completed without the user having failed. + /// + public bool GameplayPassed { get; private set; } + public Action RestartRequested; public bool HasFailed { get; private set; } @@ -666,6 +672,7 @@ namespace osu.Game.Screens.Play resultsDisplayDelegate?.Cancel(); resultsDisplayDelegate = null; + GameplayPassed = false; ValidForResume = true; skipOutroOverlay.Hide(); return; @@ -675,6 +682,8 @@ namespace osu.Game.Screens.Play if (HealthProcessor.HasFailed) return; + GameplayPassed = true; + // Setting this early in the process means that even if something were to go wrong in the order of events following, there // is no chance that a user could return to the (already completed) Player instance from a child screen. ValidForResume = false; @@ -762,13 +771,21 @@ namespace osu.Game.Screens.Play // This player instance may already be in the process of exiting. return; - this.Push(CreateResults(prepareScoreForDisplayTask.Result)); + this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely())); }, Time.Current + delay, 50); Scheduler.Add(resultsDisplayDelegate); } - protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; + protected override bool OnScroll(ScrollEvent e) + { + // During pause, allow global volume adjust regardless of settings. + if (GameplayClockContainer.IsPaused.Value) + return false; + + // Block global volume adjust if the user has asked for it (special case when holding "Alt"). + return mouseWheelDisabled.Value && !e.AltPressed; + } #region Fail Logic @@ -913,6 +930,8 @@ namespace osu.Game.Screens.Play b.IsBreakTime.BindTo(breakTracker.IsBreakTime); b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); + + failAnimationLayer.Background = b; }); HUDOverlay.IsBreakTime.BindTo(breakTracker.IsBreakTime); @@ -1023,13 +1042,13 @@ namespace osu.Game.Screens.Play // // Until we better define the server-side logic behind this, let's not store the online ID to avoid potential unique constraint // conflicts across various systems (ie. solo and multiplayer). - long? onlineScoreId = score.ScoreInfo.OnlineScoreID; - score.ScoreInfo.OnlineScoreID = null; + long? onlineScoreId = score.ScoreInfo.OnlineID; + score.ScoreInfo.OnlineID = -1; await scoreManager.Import(score.ScoreInfo, replayReader).ConfigureAwait(false); // ... And restore the online ID for other processes to handle correctly (e.g. de-duplication for the results screen). - score.ScoreInfo.OnlineScoreID = onlineScoreId; + score.ScoreInfo.OnlineID = onlineScoreId; } /// diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 57db411571..6009c85583 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -468,12 +468,14 @@ namespace osu.Game.Screens.Play private int restartCount; + private const double volume_requirement = 0.05; + private void showMuteWarningIfNeeded() { if (!muteWarningShownOnce.Value) { // Checks if the notification has not been shown yet and also if master volume is muted, track/music volume is muted or if the whole game is muted. - if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= audioManager.Volume.MinValue || audioManager.VolumeTrack.Value <= audioManager.VolumeTrack.MinValue) + if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= volume_requirement || audioManager.VolumeTrack.Value <= volume_requirement) { notificationOverlay?.Post(new MutedNotification()); muteWarningShownOnce.Value = true; @@ -487,22 +489,26 @@ namespace osu.Game.Screens.Play public MutedNotification() { - Text = "Your music volume is set to 0%! Click here to restore it."; + Text = "Your game volume is too low to hear anything! Click here to restore it."; } [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audioManager, NotificationOverlay notificationOverlay, VolumeOverlay volumeOverlay) { Icon = FontAwesome.Solid.VolumeMute; - IconBackgound.Colour = colours.RedDark; + IconBackground.Colour = colours.RedDark; Activated = delegate { notificationOverlay.Hide(); volumeOverlay.IsMuted.Value = false; - audioManager.Volume.SetDefault(); - audioManager.VolumeTrack.SetDefault(); + + // Check values before resetting, as the user may have only had mute enabled, in which case we might not need to adjust volumes. + if (audioManager.Volume.Value <= volume_requirement) + audioManager.Volume.SetDefault(); + if (audioManager.VolumeTrack.Value <= volume_requirement) + audioManager.VolumeTrack.SetDefault(); return true; }; @@ -542,7 +548,7 @@ namespace osu.Game.Screens.Play private void load(OsuColour colours, NotificationOverlay notificationOverlay) { Icon = FontAwesome.Solid.BatteryQuarter; - IconBackgound.Colour = colours.RedDark; + IconBackground.Colour = colours.RedDark; Activated = delegate { diff --git a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs index 725a6e86bf..b1063966da 100644 --- a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Screens.Play.PlayerSettings { @@ -18,7 +19,7 @@ namespace osu.Game.Screens.Play.PlayerSettings { mouseButtonsCheckbox = new PlayerCheckbox { - LabelText = "Disable mouse buttons" + LabelText = MouseSettingsStrings.DisableMouseButtons } }; } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs index 7928d41e3b..0bbe6902f4 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs @@ -1,165 +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.Allocation; -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.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; +using osu.Game.Overlays; namespace osu.Game.Screens.Play.PlayerSettings { - public abstract class PlayerSettingsGroup : Container + public class PlayerSettingsGroup : SettingsToolboxGroup { - private const float transition_duration = 250; - private const int container_width = 270; - private const int border_thickness = 2; - private const int header_height = 30; - private const int corner_radius = 5; - - private readonly FillFlowContainer content; - private readonly IconButton button; - - private bool expanded = true; - - public bool Expanded + public PlayerSettingsGroup(string title) + : base(title) { - get => expanded; - set - { - if (expanded == value) return; - - expanded = value; - - content.ClearTransforms(); - - if (expanded) - content.AutoSizeAxes = Axes.Y; - else - { - content.AutoSizeAxes = Axes.None; - content.ResizeHeightTo(0, transition_duration, Easing.OutQuint); - } - - updateExpanded(); - } - } - - private Color4 expandedColour; - - /// - /// Create a new instance. - /// - /// The title to be displayed in the header of this group. - protected PlayerSettingsGroup(string title) - { - AutoSizeAxes = Axes.Y; - Width = container_width; - Masking = true; - CornerRadius = corner_radius; - BorderColour = Color4.Black; - BorderThickness = border_thickness; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.5f, - }, - new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new Container - { - Name = @"Header", - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = header_height, - Children = new Drawable[] - { - new OsuSpriteText - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Text = title.ToUpperInvariant(), - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), - Margin = new MarginPadding { Left = 10 }, - }, - button = new IconButton - { - Origin = Anchor.Centre, - Anchor = Anchor.CentreRight, - Position = new Vector2(-15, 0), - Icon = FontAwesome.Solid.Bars, - Scale = new Vector2(0.75f), - Action = () => Expanded = !Expanded, - }, - } - }, - 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), - } - } - }, - }; - } - - private const float fade_duration = 800; - private const float inactive_alpha = 0.5f; - - protected override void LoadComplete() - { - base.LoadComplete(); - this.Delay(600).FadeTo(inactive_alpha, fade_duration, Easing.OutQuint); } protected override bool OnHover(HoverEvent e) { - this.FadeIn(fade_duration, Easing.OutQuint); + base.OnHover(e); + + // Importantly, return true to correctly take focus away from PlayerLoader. return true; } - - protected override void OnHoverLost(HoverLostEvent e) - { - this.FadeTo(inactive_alpha, fade_duration, Easing.OutQuint); - base.OnHoverLost(e); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - expandedColour = colours.Yellow; - - updateExpanded(); - } - - private void updateExpanded() => button.FadeColour(expanded ? expandedColour : Color4.White, 200, Easing.InOutQuint); - - protected override Container Content => content; - - protected override bool OnMouseDown(MouseDownEvent e) => true; } } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index 9903a74043..57ffe16f76 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -15,12 +15,12 @@ namespace osu.Game.Screens.Play.PlayerSettings { public OsuSliderBar Bar => (OsuSliderBar)Control; - protected override Drawable CreateControl() => new Sliderbar + protected override Drawable CreateControl() => new SliderBar { RelativeSizeAxes = Axes.X }; - private class Sliderbar : OsuSliderBar + private class SliderBar : OsuSliderBar { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs index 45601999a0..b530965269 100644 --- a/osu.Game/Screens/Play/SoloSpectator.cs +++ b/osu.Game/Screens/Play/SoloSpectator.cs @@ -23,12 +23,10 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Overlays.Settings; -using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.Spectate; using osu.Game.Users; using osuTK; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Screens.Play { @@ -44,9 +42,6 @@ namespace osu.Game.Screens.Play [Resolved] private PreviewTrackManager previewTrackManager { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - [Resolved] private BeatmapManager beatmaps { get; set; } @@ -233,7 +228,7 @@ namespace osu.Game.Screens.Play onlineBeatmapRequest.Success += beatmapSet => Schedule(() => { this.beatmapSet = beatmapSet; - beatmapPanelContainer.Child = new BeatmapCard(this.beatmapSet); + beatmapPanelContainer.Child = new BeatmapCardNormal(this.beatmapSet, allowExpansion: false); checkForAutomaticDownload(); }); diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index f6a89e7fa9..d42643c416 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Screens.Play private void userSentFrames(int userId, FrameDataBundle bundle) { - if (userId != score.ScoreInfo.User.Id) + if (userId != score.ScoreInfo.User.OnlineID) return; if (!LoadedBeatmapSuccessfully) diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 76411c8c6b..2cf56be659 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -6,6 +6,7 @@ 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.Online.API; @@ -75,7 +76,7 @@ namespace osu.Game.Screens.Play api.Queue(req); - tcs.Task.Wait(); + tcs.Task.WaitSafely(); return true; void handleTokenFailure(Exception exception) @@ -156,7 +157,9 @@ namespace osu.Game.Screens.Play request.Success += s => { - score.ScoreInfo.OnlineScoreID = s.ID; + score.ScoreInfo.OnlineID = s.ID; + score.ScoreInfo.Position = s.Position; + scoreSubmissionSource.SetResult(true); }; diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs index 0935ee7fb2..beff509dc6 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs @@ -2,36 +2,42 @@ // 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.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Contracted { public class ContractedPanelTopContent : CompositeDrawable { - private readonly ScoreInfo score; + public readonly Bindable ScorePosition = new Bindable(); - public ContractedPanelTopContent(ScoreInfo score) + private OsuSpriteText text; + + public ContractedPanelTopContent() { - this.score = score; - RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load() { - InternalChild = new OsuSpriteText + InternalChild = text = new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Y = 6, - Text = score.Position != null ? $"#{score.Position}" : string.Empty, Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScorePosition.BindValueChanged(pos => text.Text = pos.NewValue != null ? $"#{pos.NewValue}" : string.Empty, true); + } } } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index 635be60549..e50520e0ca 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -10,7 +10,6 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Platform; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Graphics; @@ -104,7 +103,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy } [BackgroundDependencyLoader] - private void load(GameHost host) + private void load() { InternalChildren = new Drawable[] { diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 3ab2658f97..7e39708e65 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -79,7 +80,7 @@ namespace osu.Game.Screens.Ranking.Expanded statisticDisplays.AddRange(topStatistics); statisticDisplays.AddRange(bottomStatistics); - var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).Result; + var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); AddInternal(new FillFlowContainer { diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 68da4ec724..d6e4cfbe51 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -5,6 +5,7 @@ using System; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; @@ -37,7 +38,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics else { performanceCache.CalculatePerformanceAsync(score, cancellationTokenSource.Token) - .ContinueWith(t => Schedule(() => setPerformanceValue(t.Result)), cancellationTokenSource.Token); + .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely())), cancellationTokenSource.Token); } } diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index 6ddecf8297..bc6eb9e366 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -4,6 +4,7 @@ using System; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -78,6 +79,11 @@ namespace osu.Game.Screens.Ranking public event Action StateChanged; + /// + /// The position of the score in the rankings. + /// + public readonly Bindable ScorePosition = new Bindable(); + /// /// An action to be invoked if this is clicked while in an expanded state. /// @@ -103,6 +109,8 @@ namespace osu.Game.Screens.Ranking { Score = score; displayWithFlair = isNewLocalScore; + + ScorePosition.Value = score.Position; } [BackgroundDependencyLoader] @@ -211,8 +219,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).With(d => d.Alpha = 0)); - middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair).With(d => d.Alpha = 0)); + topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User) { Alpha = 0 }); + middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair) { Alpha = 0 }); // only the first expanded display should happen with flair. displayWithFlair = false; @@ -224,8 +232,13 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(contracted_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); middleLayerBackground.FadeColour(contracted_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); - topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent(Score).With(d => d.Alpha = 0)); - middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); + topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent + { + ScorePosition = { BindTarget = ScorePosition }, + Alpha = 0 + }); + + middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score) { Alpha = 0 }); break; } diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index d5b8a4c8ea..f8e9d08350 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -10,10 +10,10 @@ 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; -using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Scoring; using osuTK; @@ -70,9 +70,6 @@ namespace osu.Game.Screens.Ranking [Resolved] private ScoreManager scoreManager { get; set; } - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; @@ -155,9 +152,9 @@ namespace osu.Game.Screens.Ranking // Calculating score can take a while in extreme scenarios, so only display scores after the process completes. scoreManager.GetTotalScoreAsync(score) - .ContinueWith(totalScore => Schedule(() => + .ContinueWith(task => Schedule(() => { - flow.SetLayoutPosition(trackingContainer, totalScore.Result); + flow.SetLayoutPosition(trackingContainer, task.GetResultSafely()); trackingContainer.Show(); @@ -345,7 +342,7 @@ namespace osu.Game.Screens.Ranking private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() .OrderByDescending(GetLayoutPosition) - .ThenBy(s => s.Panel.Score.OnlineScoreID); + .ThenBy(s => s.Panel.Score.OnlineID); } private class Scroll : OsuScrollContainer diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 929bda6508..afebc728b4 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -31,7 +31,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.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); + getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.CreateScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); return getScoreRequest; } diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs index df8c68a0dd..0fd39db97c 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs @@ -9,7 +9,6 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Shapes; @@ -83,7 +82,7 @@ namespace osu.Game.Screens.Select } [BackgroundDependencyLoader] - private void load(OsuColour colour, OsuConfigManager config) + private void load(OsuColour colour) { modsCheckbox.AccentColour = tabs.AccentColour = colour.YellowLight; } diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 7543c89f17..bbe0a37d8e 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -16,7 +16,6 @@ using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Rulesets; using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; @@ -38,9 +37,6 @@ namespace osu.Game.Screens.Select [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - private IBeatmapInfo beatmapInfo; private APIFailTimes failTimes; diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index e4cf9bd868..6791565828 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -163,7 +163,7 @@ namespace osu.Game.Screens.Select private FillFlowContainer infoLabelContainer; private Container bpmLabelContainer; - private readonly WorkingBeatmap beatmap; + private readonly WorkingBeatmap working; private readonly RulesetInfo ruleset; [Resolved] @@ -171,10 +171,10 @@ namespace osu.Game.Screens.Select private ModSettingChangeTracker settingChangeTracker; - public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset) + public WedgeInfoText(WorkingBeatmap working, RulesetInfo userRuleset) { - this.beatmap = beatmap; - ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; + this.working = working; + ruleset = userRuleset ?? working.BeatmapInfo.Ruleset; } private CancellationTokenSource cancellationSource; @@ -183,8 +183,8 @@ namespace osu.Game.Screens.Select [BackgroundDependencyLoader] private void load(OsuColour colours, LocalisationManager localisation, BeatmapDifficultyCache difficultyCache) { - var beatmapInfo = beatmap.BeatmapInfo; - var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); + var beatmapInfo = working.BeatmapInfo; + var metadata = beatmapInfo.Metadata ?? working.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); RelativeSizeAxes = Axes.Both; @@ -330,36 +330,9 @@ namespace osu.Game.Screens.Select addInfoLabels(); } - private void setMetadata(string source) + protected override void LoadComplete() { - ArtistLabel.Text = artistBinding.Value; - TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value; - } - - private void addInfoLabels() - { - if (beatmap.Beatmap?.HitObjects?.Any() != true) - return; - - infoLabelContainer.Children = new Drawable[] - { - new InfoLabel(new BeatmapStatistic - { - Name = "Length", - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = beatmap.BeatmapInfo.Length.ToFormattedDuration().ToString(), - }), - bpmLabelContainer = new Container - { - AutoSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(20, 0), - Children = getRulesetInfoLabels() - } - }; + base.LoadComplete(); mods.BindValueChanged(m => { @@ -372,6 +345,38 @@ namespace osu.Game.Screens.Select }, true); } + private void setMetadata(string source) + { + ArtistLabel.Text = artistBinding.Value; + TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value; + } + + private void addInfoLabels() + { + if (working.Beatmap?.HitObjects?.Any() != true) + return; + + infoLabelContainer.Children = new Drawable[] + { + new InfoLabel(new BeatmapStatistic + { + Name = "Length", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), + Content = working.BeatmapInfo.Length.ToFormattedDuration().ToString(), + }), + bpmLabelContainer = new Container + { + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(20, 0), + Children = getRulesetInfoLabels() + } + }; + } + private InfoLabel[] getRulesetInfoLabels() { try @@ -381,12 +386,12 @@ namespace osu.Game.Screens.Select try { // Try to get the beatmap with the user's ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); + playableBeatmap = working.GetPlayableBeatmap(ruleset, Array.Empty()); } catch (BeatmapInvalidForRulesetException) { // Can't be converted to the user's ruleset, so use the beatmap's own ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); + playableBeatmap = working.GetPlayableBeatmap(working.BeatmapInfo.Ruleset, Array.Empty()); } return playableBeatmap.GetStatistics().Select(s => new InfoLabel(s)).ToArray(); @@ -401,8 +406,9 @@ namespace osu.Game.Screens.Select private void refreshBPMLabel() { - var b = beatmap.Beatmap; - if (b == null) + var beatmap = working.Beatmap; + + if (beatmap == null || bpmLabelContainer == null) return; // this doesn't consider mods which apply variable rates, yet. @@ -410,9 +416,9 @@ namespace osu.Game.Screens.Select foreach (var mod in mods.Value.OfType()) rate = mod.ApplyToRate(0, rate); - double bpmMax = b.ControlPointInfo.BPMMaximum * rate; - double bpmMin = b.ControlPointInfo.BPMMinimum * rate; - double mostCommonBPM = 60000 / b.GetMostCommonBeatLength() * rate; + double bpmMax = beatmap.ControlPointInfo.BPMMaximum * rate; + double bpmMin = beatmap.ControlPointInfo.BPMMinimum * rate; + double mostCommonBPM = 60000 / beatmap.GetMostCommonBeatLength() * rate; string labelText = Precision.AlmostEquals(bpmMin, bpmMax) ? $"{bpmMin:0}" diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index cc2db6ed31..d2c7c75da8 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -28,8 +28,8 @@ namespace osu.Game.Screens.Select.Carousel bool match = criteria.Ruleset == null || - BeatmapInfo.RulesetID == criteria.Ruleset.ID || - (BeatmapInfo.RulesetID == 0 && criteria.Ruleset.ID > 0 && criteria.AllowConvertedBeatmaps); + BeatmapInfo.RulesetID == criteria.Ruleset.OnlineID || + (BeatmapInfo.RulesetID == 0 && criteria.Ruleset.OnlineID > 0 && criteria.AllowConvertedBeatmaps); if (BeatmapInfo.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) { diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index edbaba40bc..adaaa6425c 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -16,6 +16,7 @@ using osu.Game.Rulesets.Mods; using System.Linq; using System.Threading; using System.Threading.Tasks; +using osu.Framework.Extensions; using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Framework.Utils; @@ -147,15 +148,18 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource = new CancellationTokenSource(); - var normalStarDifficulty = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, null, starDifficultyCancellationSource.Token); - var moddedStarDifficulty = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, null, starDifficultyCancellationSource.Token); + var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); - Task.WhenAll(normalStarDifficulty, moddedStarDifficulty).ContinueWith(_ => Schedule(() => + Task.WhenAll(normalStarDifficultyTask, moddedStarDifficultyTask).ContinueWith(_ => Schedule(() => { - if (normalStarDifficulty.Result == null || moddedStarDifficulty.Result == null) + var normalDifficulty = normalStarDifficultyTask.GetResultSafely(); + var moddedDifficulty = moddedStarDifficultyTask.GetResultSafely(); + + if (normalDifficulty == null || moddedDifficulty == null) return; - starDifficulty.Value = ((float)normalStarDifficulty.Result.Value.Stars, (float)moddedStarDifficulty.Result.Value.Stars); + starDifficulty.Value = ((float)normalDifficulty.Value.Stars, (float)moddedDifficulty.Value.Stars); }), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 1fd6d8c921..0102986070 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -8,6 +8,7 @@ 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.Online.API; using osu.Game.Online.API.Requests; @@ -64,9 +65,6 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private ScoreManager scoreManager { get; set; } - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - [Resolved] private IBindable ruleset { get; set; } @@ -118,7 +116,9 @@ namespace osu.Game.Screens.Select.Leaderboards var cancellationToken = loadCancellationSource.Token; - if (BeatmapInfo == null) + var fetchBeatmapInfo = BeatmapInfo; + + if (fetchBeatmapInfo == null) { PlaceholderState = PlaceholderState.NoneSelected; return null; @@ -127,7 +127,7 @@ namespace osu.Game.Screens.Select.Leaderboards if (Scope == BeatmapLeaderboardScope.Local) { var scores = scoreManager - .QueryScores(s => !s.DeletePending && s.BeatmapInfo.ID == BeatmapInfo.ID && s.Ruleset.ID == ruleset.Value.ID); + .QueryScores(s => !s.DeletePending && s.BeatmapInfo.ID == fetchBeatmapInfo.ID && s.Ruleset.ID == ruleset.Value.ID); if (filterMods && !mods.Value.Any()) { @@ -143,7 +143,7 @@ namespace osu.Game.Screens.Select.Leaderboards } scoreManager.OrderByTotalScoreAsync(scores.ToArray(), cancellationToken) - .ContinueWith(ordered => scoresCallback?.Invoke(ordered.Result), TaskContinuationOptions.OnlyOnRanToCompletion); + .ContinueWith(task => scoresCallback?.Invoke(task.GetResultSafely()), TaskContinuationOptions.OnlyOnRanToCompletion); return null; } @@ -154,7 +154,7 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - if (BeatmapInfo.OnlineID == null || BeatmapInfo?.Status <= BeatmapOnlineStatus.Pending) + if (fetchBeatmapInfo.OnlineID == null || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) { PlaceholderState = PlaceholderState.Unavailable; return null; @@ -174,18 +174,18 @@ namespace osu.Game.Screens.Select.Leaderboards else if (filterMods) requestMods = mods.Value; - var req = new GetScoresRequest(BeatmapInfo, ruleset.Value ?? BeatmapInfo.Ruleset, Scope, requestMods); + var req = new GetScoresRequest(fetchBeatmapInfo, ruleset.Value ?? fetchBeatmapInfo.Ruleset, Scope, requestMods); req.Success += r => { - scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets, BeatmapInfo)).ToArray(), cancellationToken) - .ContinueWith(ordered => Schedule(() => + scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets, fetchBeatmapInfo)).ToArray(), cancellationToken) + .ContinueWith(task => Schedule(() => { if (cancellationToken.IsCancellationRequested) return; - scoresCallback?.Invoke(ordered.Result); - TopScore = r.UserScore?.CreateScoreInfo(rulesets, BeatmapInfo); + scoresCallback?.Invoke(task.GetResultSafely()); + TopScore = r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo); }), TaskContinuationOptions.OnlyOnRanToCompletion); }; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index f715c7ff59..08ad9f2ec0 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -807,14 +807,14 @@ namespace osu.Game.Screens.Select private void delete(BeatmapSetInfo beatmap) { - if (beatmap == null || beatmap.ID <= 0) return; + if (beatmap == null || !beatmap.IsManaged) return; dialogOverlay?.Push(new BeatmapDeleteDialog(beatmap)); } private void clearScores(BeatmapInfo beatmapInfo) { - if (beatmapInfo == null || beatmapInfo.ID <= 0) return; + if (beatmapInfo == null || !beatmapInfo.IsManaged) return; dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmapInfo, () => // schedule done here rather than inside the dialog as the dialog may fade out and never callback. diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index ca56366927..c4e75cc413 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -7,6 +7,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Database; @@ -57,9 +58,11 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); - userLookupCache.GetUsersAsync(users.ToArray()).ContinueWith(users => Schedule(() => + userLookupCache.GetUsersAsync(users.ToArray()).ContinueWith(task => Schedule(() => { - foreach (var u in users.Result) + var foundUsers = task.GetResultSafely(); + + foreach (var u in foundUsers) { if (u == null) continue; diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index 57c08a903f..2ba301deed 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Configuration; +using osu.Game.Storyboards; namespace osu.Game.Skinning { @@ -56,12 +57,12 @@ namespace osu.Game.Skinning return beatmapSkins.Value; } - protected override bool AllowSampleLookup(ISampleInfo componentName) + protected override bool AllowSampleLookup(ISampleInfo sampleInfo) { if (beatmapSkins == null) throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); - return beatmapHitsounds.Value; + return sampleInfo is StoryboardSampleInfo || beatmapHitsounds.Value; } public BeatmapSkinProvidingContainer(ISkin skin) diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index cd6dbd9ddd..c7033d37dc 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -12,8 +12,17 @@ namespace osu.Game.Skinning { public class DefaultLegacySkin : LegacySkin { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. + Name = "osu!classic", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() + }; + public DefaultLegacySkin(IStorageResourceProvider resources) - : this(Info, resources) + : this(CreateInfo(), resources) { } @@ -25,7 +34,7 @@ namespace osu.Game.Skinning resources, // A default legacy skin may still have a skin.ini if it is modified by the user. // We must specify the stream directly as we are redirecting storage to the osu-resources location for other files. - new LegacySkinResourceStore(skin, resources.Files).GetStream("skin.ini") + new LegacyDatabasedSkinResourceStore(skin, resources.Files).GetStream("skin.ini") ) { Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255); @@ -39,13 +48,5 @@ namespace osu.Game.Skinning Configuration.LegacyVersion = 2.7m; } - - public static SkinInfo Info { get; } = new SkinInfo - { - ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. - Name = "osu!classic", - Creator = "team osu!", - InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() - }; } } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index c377f16f8b..951e3f9cc5 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -23,10 +23,19 @@ namespace osu.Game.Skinning { public class DefaultSkin : Skin { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN, + Name = "osu! (triangles)", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() + }; + private readonly IStorageResourceProvider resources; public DefaultSkin(IStorageResourceProvider resources) - : this(SkinInfo.Default, resources) + : this(CreateInfo(), resources) { } diff --git a/osu.Game/Skinning/EFSkinInfo.cs b/osu.Game/Skinning/EFSkinInfo.cs new file mode 100644 index 0000000000..50588563be --- /dev/null +++ b/osu.Game/Skinning/EFSkinInfo.cs @@ -0,0 +1,63 @@ +// 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 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/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index d27122aea8..86854ab6ff 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -1,15 +1,12 @@ // 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 JetBrains.Annotations; -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.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; @@ -28,9 +25,6 @@ namespace osu.Game.Skinning.Editor public const float VISIBLE_TARGET_SCALE = 0.8f; - [Resolved] - private OsuColour colours { get; set; } - public SkinEditorOverlay(ScalingContainer target) { this.target = target; @@ -72,18 +66,34 @@ namespace osu.Game.Skinning.Editor public override void Show() { - // base call intentionally omitted. - if (skinEditor == null) + // base call intentionally omitted as we have custom behaviour. + + if (skinEditor != null) { - skinEditor = new SkinEditor(target); - skinEditor.State.BindValueChanged(editorVisibilityChanged); - - Debug.Assert(skinEditor != null); - - LoadComponentAsync(skinEditor, AddInternal); - } - else skinEditor.Show(); + return; + } + + var editor = new SkinEditor(target); + editor.State.BindValueChanged(editorVisibilityChanged); + + skinEditor = editor; + + // Schedule ensures that if `Show` is called before this overlay is loaded, + // it will not throw (LoadComponentAsync requires the load target to be in a loaded state). + Schedule(() => + { + if (editor != skinEditor) + return; + + LoadComponentAsync(editor, _ => + { + if (editor != skinEditor) + return; + + AddInternal(editor); + }); + }); } private void editorVisibilityChanged(ValueChangedEvent visibility) diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 01bd5e8196..bd6d097eb2 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -126,7 +126,7 @@ namespace osu.Game.Skinning.Editor return true; } - public override bool HandleFlip(Direction direction) + public override bool HandleFlip(Direction direction, bool flipOverOrigin) { var selectionQuad = getSelectionQuad(); Vector2 scaleFactor = direction == Direction.Horizontal ? new Vector2(-1, 1) : new Vector2(1, -1); @@ -135,7 +135,7 @@ namespace osu.Game.Skinning.Editor { var drawableItem = (Drawable)b.Item; - var flippedPosition = GetFlippedPosition(direction, selectionQuad, b.ScreenSpaceSelectionPoint); + var flippedPosition = GetFlippedPosition(direction, flipOverOrigin ? drawableItem.Parent.ScreenSpaceDrawQuad : selectionQuad, b.ScreenSpaceSelectionPoint); updateDrawablePosition(drawableItem, flippedPosition); diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index fd5a9500d9..bdcb85456a 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osuTK; @@ -23,9 +21,6 @@ namespace osu.Game.Skinning Margin = new MarginPadding(10); } - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score) { Anchor = Anchor.TopRight, diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 8abef6800d..d44d3dce49 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -21,7 +21,7 @@ namespace osu.Game.Skinning protected override bool UseCustomSampleBanks => true; public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore storage, IStorageResourceProvider resources) - : base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path) + : base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path) { // Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer) Configuration.AllowDefaultComboColoursFallback = false; @@ -77,6 +77,6 @@ namespace osu.Game.Skinning } private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) => - new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username }; + new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username ?? string.Empty }; } } diff --git a/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs b/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs new file mode 100644 index 0000000000..cd90fea9bb --- /dev/null +++ b/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs @@ -0,0 +1,43 @@ +// 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.Extensions; +using osu.Framework.IO.Stores; +using osu.Game.Extensions; + +namespace osu.Game.Skinning +{ + public class LegacyDatabasedSkinResourceStore : ResourceStore + { + private readonly Dictionary fileToStoragePathMapping = new Dictionary(); + + public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore underlyingStore) + : base(underlyingStore) + { + initialiseFileCache(source); + } + + private void initialiseFileCache(SkinInfo source) + { + fileToStoragePathMapping.Clear(); + foreach (var f in source.Files) + fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath(); + } + + protected override IEnumerable GetFilenames(string name) + { + foreach (string filename in base.GetFilenames(name)) + { + string path = getPathForFile(filename.ToStandardisedPath()); + if (path != null) + yield return path; + } + } + + private string getPathForFile(string filename) => + fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null; + + public override IEnumerable GetAvailableResources() => fileToStoragePathMapping.Keys; + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 0e7ae95169..e677e2c01b 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -51,7 +51,7 @@ namespace osu.Game.Skinning [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] public LegacySkin(SkinInfo skin, IStorageResourceProvider resources) - : this(skin, new LegacySkinResourceStore(skin, resources.Files), resources, "skin.ini") + : this(skin, new LegacyDatabasedSkinResourceStore(skin, resources.Files), resources, "skin.ini") { } diff --git a/osu.Game/Skinning/LegacySkinResourceStore.cs b/osu.Game/Skinning/LegacySkinResourceStore.cs index c4418baeff..2487a469c8 100644 --- a/osu.Game/Skinning/LegacySkinResourceStore.cs +++ b/osu.Game/Skinning/LegacySkinResourceStore.cs @@ -11,12 +11,11 @@ using osu.Game.Extensions; namespace osu.Game.Skinning { - public class LegacySkinResourceStore : ResourceStore - where T : INamedFileInfo + public class LegacySkinResourceStore : ResourceStore { - private readonly IHasFiles source; + private readonly IHasNamedFiles source; - public LegacySkinResourceStore(IHasFiles source, IResourceStore underlyingStore) + public LegacySkinResourceStore(IHasNamedFiles source, IResourceStore underlyingStore) : base(underlyingStore) { this.source = source; @@ -33,7 +32,7 @@ namespace osu.Game.Skinning } private string getPathForFile(string filename) => - source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath(); + source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); public override IEnumerable GetAvailableResources() => source.Files.Select(f => f.Filename); } diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index 3fcca74fb8..5db4f00b46 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -4,7 +4,6 @@ using System; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -30,9 +29,6 @@ namespace osu.Game.Skinning private ISampleInfo sampleInfo; private SampleChannel activeChannel; - [Resolved] - private ISampleStore sampleStore { get; set; } - /// /// Creates a new with no applied . /// An can be applied later via . diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 10526b69af..d606d94b97 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Game.Audio; +using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Screens.Play.HUD; @@ -23,7 +24,7 @@ namespace osu.Game.Skinning { public abstract class Skin : IDisposable, ISkin { - public readonly SkinInfo SkinInfo; + public readonly ILive SkinInfo; private readonly IStorageResourceProvider resources; public SkinConfiguration Configuration { get; set; } @@ -42,7 +43,11 @@ namespace osu.Game.Skinning protected Skin(SkinInfo skin, IStorageResourceProvider resources, [CanBeNull] Stream configurationStream = null) { - SkinInfo = skin; + SkinInfo = resources?.RealmContextFactory != null + ? skin.ToLive(resources.RealmContextFactory) + // This path should only be used in some tests. + : skin.ToLiveUnmanaged(); + this.resources = resources; configurationStream ??= getConfigurationStream(); @@ -53,37 +58,41 @@ namespace osu.Game.Skinning else Configuration = new SkinConfiguration(); - // we may want to move this to some kind of async operation in the future. - foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) + // skininfo files may be null for default skin. + SkinInfo.PerformRead(s => { - string filename = $"{skinnableTarget}.json"; - - // skininfo files may be null for default skin. - var fileInfo = SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); - - if (fileInfo == null) - continue; - - byte[] bytes = resources?.Files.Get(fileInfo.FileInfo.GetStoragePath()); - - if (bytes == null) - continue; - - try + // we may want to move this to some kind of async operation in the future. + foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) { - string jsonContent = Encoding.UTF8.GetString(bytes); - var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + string filename = $"{skinnableTarget}.json"; - if (deserializedContent == null) + // skininfo files may be null for default skin. + var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename); + + if (fileInfo == null) continue; - DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath()); + + if (bytes == null) + continue; + + try + { + string jsonContent = Encoding.UTF8.GetString(bytes); + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + + if (deserializedContent == null) + continue; + + DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load skin configuration."); + } } - catch (Exception ex) - { - Logger.Error(ex, "Failed to load skin configuration."); - } - } + }); } protected virtual void ParseConfigurationStream(Stream stream) @@ -94,7 +103,7 @@ namespace osu.Game.Skinning private Stream getConfigurationStream() { - string path = SkinInfo.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath(); + string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath()); if (string.IsNullOrEmpty(path)) return null; diff --git a/osu.Game/Skinning/SkinFileInfo.cs b/osu.Game/Skinning/SkinFileInfo.cs index db7cd953bb..4f1bf68e51 100644 --- a/osu.Game/Skinning/SkinFileInfo.cs +++ b/osu.Game/Skinning/SkinFileInfo.cs @@ -11,8 +11,12 @@ namespace osu.Game.Skinning { 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; } diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 5d2d51a9b0..fee8c3edb2 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -3,28 +3,43 @@ using System; using System.Collections.Generic; +using Newtonsoft.Json; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Testing; using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Models; +using Realms; + +#nullable enable namespace osu.Game.Skinning { - public class SkinInfo : IHasFiles, IEquatable, IHasPrimaryKey, ISoftDelete, IHasNamedFiles + [ExcludeFromDynamicCompile] + [MapTo("Skin")] + [JsonObject(MemberSerialization.OptIn)] + public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles { - internal const int DEFAULT_SKIN = 0; - internal const int CLASSIC_SKIN = -1; - internal const int RANDOM_SKIN = -2; + internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD"); + 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"); - public int ID { get; set; } + [PrimaryKey] + [JsonProperty] + public Guid ID { get; set; } = Guid.NewGuid(); + [JsonProperty] public string Name { get; set; } = string.Empty; + [JsonProperty] public string Creator { get; set; } = string.Empty; - public string Hash { get; set; } + [JsonProperty] + public string InstantiationInfo { get; set; } = string.Empty; - public string InstantiationInfo { get; set; } + public string Hash { get; set; } = string.Empty; + + public bool Protected { get; set; } public virtual Skin CreateInstance(IStorageResourceProvider resources) { @@ -36,23 +51,21 @@ namespace osu.Game.Skinning return (Skin)Activator.CreateInstance(type, this, resources); } - public List Files { get; } = new List(); + public IList Files { get; } = null!; public bool DeletePending { get; set; } - public static SkinInfo Default { get; } = new SkinInfo + public bool Equals(SkinInfo? other) { - ID = DEFAULT_SKIN, - Name = "osu! (triangles)", - Creator = "team osu!", - InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() - }; + if (ReferenceEquals(this, other)) return true; + if (other == null) return false; - public bool Equals(SkinInfo other) => other != null && ID == other.ID; + return ID == other.ID; + } public override string ToString() { - string author = Creator == null ? string.Empty : $"({Creator})"; + string author = string.IsNullOrEmpty(Creator) ? string.Empty : $"({Creator})"; return $"{Name} {author}".Trim(); } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 26ff4457af..cde21b78c1 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -3,23 +3,22 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Linq.Expressions; -using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; +using JetBrains.Annotations; 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.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; @@ -37,20 +36,25 @@ namespace osu.Game.Skinning /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// [ExcludeFromDynamicCompile] - public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter, IModelManager + public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter { private readonly AudioManager audio; + private readonly Scheduler scheduler; + private readonly GameHost host; private readonly IResourceStore resources; public readonly Bindable CurrentSkin = new Bindable(); - public readonly Bindable CurrentSkinInfo = new Bindable(SkinInfo.Default) { Default = SkinInfo.Default }; + + public readonly Bindable> CurrentSkinInfo = new Bindable>(Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()) + { + Default = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged() + }; private readonly SkinModelManager skinModelManager; - - private readonly SkinStore skinStore; + private readonly RealmContextFactory contextFactory; private readonly IResourceStore userFiles; @@ -64,69 +68,66 @@ namespace osu.Game.Skinning /// public Skin DefaultLegacySkin { get; } - public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio) + public SkinManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) { + this.contextFactory = contextFactory; this.audio = audio; + this.scheduler = scheduler; this.host = host; this.resources = resources; - skinStore = new SkinStore(contextFactory, storage); - userFiles = new FileStore(contextFactory, storage).Store; + userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files")); - skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this); + skinModelManager = new SkinModelManager(storage, contextFactory, host, this); - DefaultLegacySkin = new DefaultLegacySkin(this); - DefaultSkin = new DefaultSkin(this); + var defaultSkins = new[] + { + DefaultLegacySkin = new DefaultLegacySkin(this), + DefaultSkin = new DefaultSkin(this), + }; - CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); + // Ensure the default entries are present. + using (var context = contextFactory.CreateContext()) + using (var transaction = context.BeginWrite()) + { + foreach (var skin in defaultSkins) + { + if (context.Find(skin.SkinInfo.ID) == null) + context.Add(skin.SkinInfo.Value); + } + + transaction.Commit(); + } + + CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); CurrentSkin.Value = DefaultSkin; CurrentSkin.ValueChanged += skin => { - if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value) + if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value)) throw new InvalidOperationException($"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead."); SourceChanged?.Invoke(); }; } - /// - /// Returns a list of all usable s. Includes the special default skin plus all skins from . - /// - /// A newly allocated list of available . - public List GetAllUsableSkins() - { - var userSkins = GetAllUserSkins(); - userSkins.Insert(0, DefaultSkin.SkinInfo); - userSkins.Insert(1, DefaultLegacySkin.SkinInfo); - return userSkins; - } - - /// - /// Returns a list of all usable s that have been loaded by the user. - /// - /// A newly allocated list of available . - public List GetAllUserSkins(bool includeFiles = false) - { - if (includeFiles) - return skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); - - return skinStore.Items.Where(s => !s.DeletePending).ToList(); - } - public void SelectRandomSkin() { - // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = skinStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); - - if (randomChoices.Length == 0) + using (var context = contextFactory.CreateContext()) { - CurrentSkinInfo.Value = SkinInfo.Default; - return; - } + // choose from only user skins, removing the current selection to ensure a new one is chosen. + var randomChoices = context.All().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); - var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); - CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID); + if (randomChoices.Length == 0) + { + CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged(); + return; + } + + var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); + + CurrentSkinInfo.Value = chosen.ToLive(contextFactory); + } } /// @@ -142,40 +143,36 @@ namespace osu.Game.Skinning /// public void EnsureMutableSkin() { - if (CurrentSkinInfo.Value.ID >= 1) return; - - var skin = CurrentSkin.Value; - - // if the user is attempting to save one of the default skin implementations, create a copy first. - CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo + CurrentSkinInfo.Value.PerformRead(s => { - Name = skin.SkinInfo.Name + @" (modified)", - Creator = skin.SkinInfo.Creator, - InstantiationInfo = skin.SkinInfo.InstantiationInfo, - }).Result.Value; + if (!s.Protected) + return; + + // if the user is attempting to save one of the default skin implementations, create a copy first. + var result = skinModelManager.Import(new SkinInfo + { + Name = s.Name + @" (modified)", + Creator = s.Creator, + InstantiationInfo = s.InstantiationInfo, + }).GetResultSafely(); + + if (result != null) + { + // save once to ensure the required json content is populated. + // currently this only happens on save. + result.PerformRead(skin => Save(skin.CreateInstance(this))); + + CurrentSkinInfo.Value = result; + } + }); } public void Save(Skin skin) { - if (skin.SkinInfo.ID <= 0) + if (!skin.SkinInfo.IsManaged) throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); - foreach (var drawableInfo in skin.DrawableComponentInfo) - { - string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); - - using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) - { - string filename = @$"{drawableInfo.Key}.json"; - - var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); - - if (oldFile != null) - skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent); - else - skinModelManager.AddFile(skin.SkinInfo, streamContent, filename); - } - } + skinModelManager.Save(skin); } /// @@ -183,7 +180,11 @@ namespace osu.Game.Skinning /// /// The query. /// The first result for the provided query, or null if no results were found. - public SkinInfo Query(Expression> query) => skinStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); + public ILive Query(Expression> query) + { + using (var context = contextFactory.CreateContext()) + return context.All().FirstOrDefault(query)?.ToLive(contextFactory); + } public event Action SourceChanged; @@ -237,6 +238,7 @@ namespace osu.Game.Skinning AudioManager IStorageResourceProvider.AudioManager => audio; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.Files => userFiles; + RealmContextFactory IStorageResourceProvider.RealmContextFactory => contextFactory; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); #endregion @@ -289,46 +291,23 @@ namespace osu.Game.Skinning #region Implementation of IModelManager - public event Action ItemUpdated + public void Delete([CanBeNull] Expression> filter = null, bool silent = false) { - add => skinModelManager.ItemUpdated += value; - remove => skinModelManager.ItemUpdated -= value; - } + using (var context = contextFactory.CreateContext()) + { + var items = context.All() + .Where(s => !s.Protected && !s.DeletePending); + if (filter != null) + items = items.Where(filter); - public event Action ItemRemoved - { - add => skinModelManager.ItemRemoved += value; - remove => skinModelManager.ItemRemoved -= value; - } + // check the removed skin is not the current user choice. if it is, switch back to default. + Guid currentUserSkin = CurrentSkinInfo.Value.ID; - public void Update(SkinInfo item) - { - skinModelManager.Update(item); - } + if (items.Any(s => s.ID == currentUserSkin)) + scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()); - public bool Delete(SkinInfo item) - { - return skinModelManager.Delete(item); - } - - public void Delete(List items, bool silent = false) - { - skinModelManager.Delete(items, silent); - } - - public void Undelete(List items, bool silent = false) - { - skinModelManager.Undelete(items, silent); - } - - public void Undelete(SkinInfo item) - { - skinModelManager.Undelete(item); - } - - public bool IsAvailableLocally(SkinInfo model) - { - return skinModelManager.IsAvailableLocally(model); + skinModelManager.Delete(items.ToList(), silent); + } } #endregion diff --git a/osu.Game/Skinning/SkinModelManager.cs b/osu.Game/Skinning/SkinModelManager.cs index 572ae5cbfc..964d99a2e5 100644 --- a/osu.Game/Skinning/SkinModelManager.cs +++ b/osu.Game/Skinning/SkinModelManager.cs @@ -8,21 +8,28 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Stores; +using Realms; + +#nullable enable namespace osu.Game.Skinning { - public class SkinModelManager : ArchiveModelManager + public class SkinModelManager : RealmArchiveModelManager { + private const string skin_info_file = "skininfo.json"; + private readonly IStorageResourceProvider skinResources; - public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources) - : base(storage, contextFactory, skinStore, host) + public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources) + : base(storage, contextFactory) { this.skinResources = skinResources; @@ -42,18 +49,55 @@ namespace osu.Game.Skinning protected override bool HasCustomHashFunction => true; - protected override string ComputeHash(SkinInfo item) + protected override Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) + { + var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file); + + if (skinInfoFile != null) + { + try + { + using (var existingStream = Files.Storage.GetStream(skinInfoFile.File.GetStoragePath())) + using (var reader = new StreamReader(existingStream)) + { + var deserialisedSkinInfo = JsonConvert.DeserializeObject(reader.ReadToEnd()); + + if (deserialisedSkinInfo != null) + { + // for now we only care about the instantiation info. + // eventually we probably want to transfer everything across. + model.InstantiationInfo = deserialisedSkinInfo.InstantiationInfo; + } + } + } + catch (Exception e) + { + LogForModel(model, $"Error during {skin_info_file} parsing, falling back to default", e); + + // Not sure if we should still run the import in the case of failure here, but let's do so for now. + model.InstantiationInfo = string.Empty; + } + } + + // Always rewrite instantiation info (even after parsing in from the skin json) for sanity. + model.InstantiationInfo = createInstance(model).GetType().GetInvariantInstantiationInfo(); + + checkSkinIniMetadata(model, realm); + + return Task.CompletedTask; + } + + private void checkSkinIniMetadata(SkinInfo item, Realm realm) { var instance = createInstance(item); // This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations. - // `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above. string skinIniSourcedName = instance.Configuration.SkinInfo.Name; string skinIniSourcedCreator = instance.Configuration.SkinInfo.Creator; string archiveName = item.Name.Replace(@".osk", string.Empty, StringComparison.OrdinalIgnoreCase); - bool isImport = item.ID == 0; + bool isImport = !item.IsManaged; if (isImport) { @@ -71,12 +115,10 @@ namespace osu.Game.Skinning // Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching. // This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place. if (skinIniSourcedName != item.Name) - updateSkinIniMetadata(item); - - return base.ComputeHash(item); + updateSkinIniMetadata(item, realm); } - private void updateSkinIniMetadata(SkinInfo item) + private void updateSkinIniMetadata(SkinInfo item, Realm realm) { string nameLine = @$"Name: {item.Name}"; string authorLine = @$"Author: {item.Creator}"; @@ -95,39 +137,47 @@ namespace osu.Game.Skinning { // In the case a skin doesn't have a skin.ini yet, let's create one. writeNewSkinIni(); - return; } - - using (Stream stream = new MemoryStream()) + else { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + using (Stream stream = new MemoryStream()) { - using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath())) - using (var sr = new StreamReader(existingStream)) + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { - string line; - while ((line = sr.ReadLine()) != null) + using (var existingStream = Files.Storage.GetStream(existingFile.File.GetStoragePath())) + using (var sr = new StreamReader(existingStream)) + { + string? line; + while ((line = sr.ReadLine()) != null) + sw.WriteLine(line); + } + + sw.WriteLine(); + + foreach (string line in newLines) sw.WriteLine(line); } - sw.WriteLine(); + ReplaceFile(existingFile, stream, realm); - foreach (string line in newLines) - sw.WriteLine(line); - } + // can be removed 20220502. + if (!ensureIniWasUpdated(item)) + { + 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); - ReplaceFile(item, existingFile, stream); + var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + if (existingIni != null) + item.Files.Remove(existingIni); - // can be removed 20220502. - if (!ensureIniWasUpdated(item)) - { - 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); - - DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))); - writeNewSkinIni(); + writeNewSkinIni(); + } } } + // The hash is already populated at this point in import. + // As we have changed files, it needs to be recomputed. + item.Hash = ComputeHash(item); + void writeNewSkinIni() { using (Stream stream = new MemoryStream()) @@ -138,8 +188,10 @@ namespace osu.Game.Skinning sw.WriteLine(line); } - AddFile(item, stream, @"skin.ini"); + AddFile(item, stream, @"skin.ini", realm); } + + item.Hash = ComputeHash(item); } } @@ -154,36 +206,61 @@ namespace osu.Game.Skinning return instance.Configuration.SkinInfo.Name == item.Name; } - protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) - { - var instance = createInstance(model); - - model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); - - model.Name = instance.Configuration.SkinInfo.Name; - model.Creator = instance.Configuration.SkinInfo.Creator; - - return Task.CompletedTask; - } - private void populateMissingHashes() { - var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray(); - - foreach (SkinInfo skin in skinsWithoutHashes) + using (var realm = ContextFactory.CreateContext()) { - try + var skinsWithoutHashes = realm.All().Where(i => !i.Protected && string.IsNullOrEmpty(i.Hash)).ToArray(); + + foreach (SkinInfo skin in skinsWithoutHashes) { - Update(skin); - } - catch (Exception e) - { - Delete(skin); - Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); + try + { + realm.Write(r => skin.Hash = ComputeHash(skin)); + } + catch (Exception e) + { + Delete(skin); + Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); + } } } } private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources); + + public void Save(Skin skin) + { + skin.SkinInfo.PerformWrite(s => + { + // Serialise out the SkinInfo itself. + string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson))) + { + AddFile(s, streamContent, skin_info_file, s.Realm); + } + + // Then serialise each of the drawable component groups into respective files. + foreach (var drawableInfo in skin.DrawableComponentInfo) + { + string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) + { + string filename = @$"{drawableInfo.Key}.json"; + + var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename); + + if (oldFile != null) + ReplaceFile(oldFile, streamContent, s.Realm); + else + AddFile(s, streamContent, filename, s.Realm); + } + } + + s.Hash = ComputeHash(s); + }); + } } } diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index c8e4c2c7b6..24198254a3 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Skinning protected virtual bool AllowTextureLookup(string componentName) => true; - protected virtual bool AllowSampleLookup(ISampleInfo componentName) => true; + protected virtual bool AllowSampleLookup(ISampleInfo sampleInfo) => true; protected virtual bool AllowConfigurationLookup => true; diff --git a/osu.Game/Skinning/SkinStore.cs b/osu.Game/Skinning/SkinStore.cs index 31cadb0a24..922d146259 100644 --- a/osu.Game/Skinning/SkinStore.cs +++ b/osu.Game/Skinning/SkinStore.cs @@ -6,7 +6,7 @@ using osu.Game.Database; namespace osu.Game.Skinning { - public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes + public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes { public SkinStore(DatabaseContextFactory contextFactory, Storage storage = null) : base(contextFactory, storage) diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f935adf7a5..c9e55c09aa 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -7,7 +7,6 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -43,9 +42,6 @@ namespace osu.Game.Skinning private readonly AudioContainer samplesContainer; - [Resolved] - private ISampleStore sampleStore { get; set; } - [Resolved(CanBeNull = true)] private IPooledSampleProvider samplePool { get; set; } diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs index 1681dad750..4aca079e2e 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -352,7 +352,7 @@ namespace osu.Game.Stores transaction.Commit(); } - return existing.ToLive(); + return existing.ToLive(ContextFactory); } LogForModel(item, @"Found existing (optimised) but failed pre-check."); @@ -387,7 +387,7 @@ namespace osu.Game.Stores existing.DeletePending = false; transaction.Commit(); - return existing.ToLive(); + return existing.ToLive(ContextFactory); } LogForModel(item, @"Found existing but failed re-use check."); @@ -416,7 +416,7 @@ namespace osu.Game.Stores throw; } - return item.ToLive(); + return item.ToLive(ContextFactory); } } diff --git a/osu.Game/Stores/RealmArchiveModelManager.cs b/osu.Game/Stores/RealmArchiveModelManager.cs new file mode 100644 index 0000000000..87a27cbbbc --- /dev/null +++ b/osu.Game/Stores/RealmArchiveModelManager.cs @@ -0,0 +1,196 @@ +// 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; +using System.Threading.Tasks; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Class which adds all the missing pieces bridging the gap between and . + /// + public abstract class RealmArchiveModelManager : RealmArchiveModelImporter, IModelManager, IModelFileManager + where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete + { + public event Action? ItemUpdated + { + // This may be brought back for beatmaps to ease integration. + // The eventual goal would be not requiring this and using realm subscriptions in its place. + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + public event Action? ItemRemoved + { + // This may be brought back for beatmaps to ease integration. + // The eventual goal would be not requiring this and using realm subscriptions in its place. + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + private readonly RealmFileStore realmFileStore; + + protected RealmArchiveModelManager(Storage storage, RealmContextFactory contextFactory) + : base(storage, contextFactory) + { + realmFileStore = new RealmFileStore(contextFactory, storage); + } + + public void DeleteFile(TModel item, RealmNamedFileUsage file) => + item.Realm.Write(() => DeleteFile(item, file, item.Realm)); + + public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents) + => item.Realm.Write(() => ReplaceFile(file, contents, item.Realm)); + + public void AddFile(TModel item, Stream contents, string filename) + => item.Realm.Write(() => AddFile(item, contents, filename, item.Realm)); + + /// + /// Delete a file from within an ongoing realm transaction. + /// + protected void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm) + { + item.Files.Remove(file); + } + + /// + /// Replace a file from within an ongoing realm transaction. + /// + protected void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm) + { + file.File = realmFileStore.Add(contents, realm); + } + + /// + /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten. + /// + protected void AddFile(TModel item, Stream contents, string filename, Realm realm) + { + var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + { + ReplaceFile(existing, contents, realm); + return; + } + + var file = realmFileStore.Add(contents, realm); + var namedUsage = new RealmNamedFileUsage(file, filename); + + item.Files.Add(namedUsage); + } + + public override async Task?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return await base.Import(item, archive, lowPriority, cancellationToken).ConfigureAwait(false); + } + + /// + /// Delete multiple items. + /// This will post notifications tracking progress. + /// + public void Delete(List items, bool silent = false) + { + if (items.Count == 0) return; + + var notification = new ProgressNotification + { + Progress = 0, + Text = $"Preparing to delete all {HumanisedModelName}s...", + CompletionText = $"Deleted all {HumanisedModelName}s!", + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + + foreach (var b in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})"; + + Delete(b); + + notification.Progress = (float)i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + + /// + /// Restore multiple items that were previously deleted. + /// This will post notifications tracking progress. + /// + public void Undelete(List items, bool silent = false) + { + if (!items.Any()) return; + + var notification = new ProgressNotification + { + CompletionText = "Restored all deleted items!", + Progress = 0, + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + + foreach (var item in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Restoring ({++i} of {items.Count})"; + + Undelete(item); + + notification.Progress = (float)i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + + public bool Delete(TModel item) + { + if (item.DeletePending) + return false; + + item.Realm.Write(r => item.DeletePending = true); + return true; + } + + public void Undelete(TModel item) + { + if (!item.DeletePending) + return; + + item.Realm.Write(r => item.DeletePending = false); + } + + public virtual bool IsAvailableLocally(TModel model) => false; // Not relevant for skins since they can't be downloaded yet. + + public void Update(TModel skin) + { + } + } +} diff --git a/osu.Game/Stores/RealmRulesetStore.cs b/osu.Game/Stores/RealmRulesetStore.cs index 0119aec9a4..93b6d29e7d 100644 --- a/osu.Game/Stores/RealmRulesetStore.cs +++ b/osu.Game/Stores/RealmRulesetStore.cs @@ -18,7 +18,7 @@ using osu.Game.Rulesets; namespace osu.Game.Stores { - public class RealmRulesetStore : IDisposable + public class RealmRulesetStore : IRulesetStore, IDisposable { private readonly RealmContextFactory realmFactory; @@ -29,9 +29,9 @@ namespace osu.Game.Stores /// /// All available rulesets. /// - public IEnumerable AvailableRulesets => availableRulesets; + public IEnumerable AvailableRulesets => availableRulesets; - private readonly List availableRulesets = new List(); + private readonly List availableRulesets = new List(); public RealmRulesetStore(RealmContextFactory realmFactory, Storage? storage = null) { @@ -64,14 +64,14 @@ namespace osu.Game.Stores /// /// The ruleset's internal ID. /// A ruleset, if available, else null. - public IRulesetInfo? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id); + public RealmRuleset? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id); /// /// Retrieve a ruleset using a known short name. /// /// The ruleset's short name. /// A ruleset, if available, else null. - public IRulesetInfo? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName); + public RealmRuleset? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName); private Assembly? resolveRulesetDependencyAssembly(object? sender, ResolveEventArgs args) { @@ -258,5 +258,13 @@ namespace osu.Game.Stores { AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } + + #region Implementation of IRulesetStore + + IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id); + IRulesetInfo? IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName); + IEnumerable IRulesetStore.AvailableRulesets => AvailableRulesets; + + #endregion } } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 6fb2f5994b..ebd1a941a8 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -33,10 +33,8 @@ namespace osu.Game.Storyboards foreach (var l in loops) { - if (!(l.EarliestDisplayedTime is double lEarliest)) - continue; - - earliestStartTime = Math.Min(earliestStartTime, lEarliest); + if (l.EarliestDisplayedTime is double loopEarliestDisplayTime) + earliestStartTime = Math.Min(earliestStartTime, l.LoopStartTime + loopEarliestDisplayTime); } if (earliestStartTime < double.MaxValue) diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 651874e4de..897d4363f1 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -6,9 +6,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Threading.Tasks; using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Audio.Track; +using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; @@ -108,37 +110,45 @@ namespace osu.Game.Tests.Beatmaps private ConvertResult convert(string name, Mod[] mods) { - var beatmap = GetBeatmap(name); - - string beforeConversion = beatmap.Serialize(); - - var converterResult = new Dictionary>(); - - var working = new ConversionWorkingBeatmap(beatmap) + var conversionTask = Task.Factory.StartNew(() => { - ConversionGenerated = (o, r, c) => + var beatmap = GetBeatmap(name); + + string beforeConversion = beatmap.Serialize(); + + var converterResult = new Dictionary>(); + + var working = new ConversionWorkingBeatmap(beatmap) { - converterResult[o] = r; - OnConversionGenerated(o, r, c); - } - }; + ConversionGenerated = (o, r, c) => + { + converterResult[o] = r; + OnConversionGenerated(o, r, c); + } + }; - working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods); + working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods); - string afterConversion = beatmap.Serialize(); + string afterConversion = beatmap.Serialize(); - Assert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap"); + Assert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap"); - return new ConvertResult - { - Mappings = converterResult.Select(r => + return new ConvertResult { - var mapping = CreateConvertMapping(r.Key); - mapping.StartTime = r.Key.StartTime; - mapping.Objects.AddRange(r.Value.SelectMany(CreateConvertValue)); - return mapping; - }).ToList() - }; + Mappings = converterResult.Select(r => + { + var mapping = CreateConvertMapping(r.Key); + mapping.StartTime = r.Key.StartTime; + mapping.Objects.AddRange(r.Value.SelectMany(CreateConvertValue)); + return mapping; + }).ToList() + }; + }, TaskCreationOptions.LongRunning); + + if (!conversionTask.Wait(10000)) + Assert.Fail("Conversion timed out"); + + return conversionTask.GetResultSafely(); } protected virtual void OnConversionGenerated(HitObject original, IEnumerable result, IBeatmapConverter beatmapConverter) diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 9d92f5c5fc..27d1de83ec 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -14,7 +15,9 @@ using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.Database; using osu.Game.IO; +using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Ranking; @@ -88,11 +91,7 @@ namespace osu.Game.Tests.Beatmaps AddStep("setup skins", () => { userSkinInfo.Files.Clear(); - userSkinInfo.Files.Add(new SkinFileInfo - { - Filename = userFile, - FileInfo = new IO.FileInfo { Hash = userFile } - }); + userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile)); beatmapInfo.BeatmapSet.Files.Clear(); beatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo @@ -121,6 +120,7 @@ namespace osu.Game.Tests.Beatmaps public IResourceStore Files => userSkinResourceStore; public new IResourceStore Resources => base.Resources; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; + RealmContextFactory IStorageResourceProvider.RealmContextFactory => null; #endregion @@ -167,7 +167,7 @@ namespace osu.Game.Tests.Beatmaps return Array.Empty(); } - public Task GetAsync(string name) + public Task GetAsync(string name, CancellationToken cancellationToken = default) { markLookup(name); return Task.FromResult(Array.Empty()); diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index d7ab769ac4..754c9044e8 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Runtime.CompilerServices; using osu.Framework.Testing; @@ -14,18 +15,25 @@ namespace osu.Game.Tests /// /// Create a new instance. /// - /// An optional suffix which will isolate this host from others called from the same method source. /// Whether to bind IPC channels. /// Whether the host should be forced to run in realtime, rather than accelerated test time. + /// Whether to bypass directory cleanup on host disposal. Should be used only if a subsequent test relies on the files still existing. /// The name of the calling method, used for test file isolation and clean-up. - public CleanRunHeadlessGameHost(string gameSuffix = @"", bool bindIPC = false, bool realtime = true, [CallerMemberName] string callingMethodName = @"") - : base(callingMethodName + gameSuffix, bindIPC, realtime) + public CleanRunHeadlessGameHost(bool bindIPC = false, bool realtime = true, bool bypassCleanup = false, [CallerMemberName] string callingMethodName = @"") + : base($"{callingMethodName}-{Guid.NewGuid()}", bindIPC, realtime, bypassCleanup: bypassCleanup) { } protected override void SetupForRun() { - Storage.DeleteDirectory(string.Empty); + try + { + Storage.DeleteDirectory(string.Empty); + } + catch + { + // May fail if a logging target has already been set via OsuStorage.ChangeTargetStorage. + } // base call needs to be run *after* storage is emptied, as it updates the (static) logger's storage and may start writing // log entries from another source if a unit test host is shared over multiple tests, causing a file access denied exception. diff --git a/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs b/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs new file mode 100644 index 0000000000..537bee6824 --- /dev/null +++ b/osu.Game/Tests/Rulesets/TestRulesetConfigCache.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.Collections.Concurrent; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Configuration; + +namespace osu.Game.Tests.Rulesets +{ + /// + /// Test implementation of a , which ensures isolation between test scenes. + /// + public class TestRulesetConfigCache : IRulesetConfigCache + { + private readonly ConcurrentDictionary configCache = new ConcurrentDictionary(); + + public IRulesetConfigManager GetConfigFor(Ruleset ruleset) => configCache.GetOrAdd(ruleset.ShortName, _ => ruleset.CreateConfig(null)); + } +} diff --git a/osu.Game/Tests/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs deleted file mode 100644 index a53cb0ae78..0000000000 --- a/osu.Game/Tests/TestScoreInfo.cs +++ /dev/null @@ -1,66 +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.Linq; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Tests.Beatmaps; - -namespace osu.Game.Tests -{ - public class TestScoreInfo : ScoreInfo - { - public TestScoreInfo(RulesetInfo ruleset, bool excessMods = false) - { - User = new APIUser - { - Id = 2, - Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", - }; - - BeatmapInfo = new TestBeatmap(ruleset).BeatmapInfo; - Ruleset = ruleset; - RulesetID = ruleset.ID ?? 0; - - Mods = excessMods - ? ruleset.CreateInstance().CreateAllMods().ToArray() - : new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; - - TotalScore = 2845370; - Accuracy = 0.95; - MaxCombo = 999; - Rank = ScoreRank.S; - Date = DateTimeOffset.Now; - - Statistics[HitResult.Miss] = 1; - Statistics[HitResult.Meh] = 50; - Statistics[HitResult.Ok] = 100; - Statistics[HitResult.Good] = 200; - Statistics[HitResult.Great] = 300; - Statistics[HitResult.Perfect] = 320; - Statistics[HitResult.SmallTickHit] = 50; - Statistics[HitResult.SmallTickMiss] = 25; - Statistics[HitResult.LargeTickHit] = 100; - Statistics[HitResult.LargeTickMiss] = 50; - Statistics[HitResult.SmallBonus] = 10; - Statistics[HitResult.SmallBonus] = 50; - - Position = 1; - } - - private class TestModHardRock : ModHardRock - { - public override double ScoreMultiplier => 1; - } - - private class TestModDoubleTime : ModDoubleTime - { - public override double ScoreMultiplier => 1; - } - } -} diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 5656704abf..7607122ef0 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer protected new MultiplayerTestSceneDependencies OnlinePlayDependencies => (MultiplayerTestSceneDependencies)base.OnlinePlayDependencies; - public bool RoomJoined => RoomManager.RoomJoined; + public bool RoomJoined => Client.RoomJoined; private readonly bool joinRoom; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 05b7c11e34..76acac3f8b 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -11,6 +11,7 @@ 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.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -31,9 +32,10 @@ namespace osu.Game.Tests.Visual.Multiplayer private readonly Bindable isConnected = new Bindable(true); public new Room? APIRoom => base.APIRoom; - public Action? RoomSetupAction; + public bool RoomJoined { get; private set; } + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -49,6 +51,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex]; private int currentIndex; + private long lastPlaylistItemId; public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) { @@ -75,7 +78,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void addUser(MultiplayerRoomUser user) { - ((IMultiplayerClient)this).UserJoined(user).Wait(); + ((IMultiplayerClient)this).UserJoined(user).WaitSafely(); // We want the user to be immediately available for testing, so force a scheduler update to run the update-bound continuation. Scheduler.Update(); @@ -91,7 +94,7 @@ namespace osu.Game.Tests.Visual.Multiplayer .Select(team => (teamID: team.ID, userCount: Room.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))) .OrderBy(pair => pair.userCount) .First().teamID; - ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, new TeamVersusUserState { TeamID = bestTeam }).Wait(); + ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, new TeamVersusUserState { TeamID = bestTeam }).WaitSafely(); break; } } @@ -126,6 +129,15 @@ namespace osu.Game.Tests.Visual.Multiplayer case MultiplayerRoomState.WaitingForLoad: if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) { + var loadedUsers = Room.Users.Where(u => u.State == MultiplayerUserState.Loaded).ToArray(); + + if (loadedUsers.Length == 0) + { + // all users have bailed from the load sequence. cancel the game start. + ChangeRoomState(MultiplayerRoomState.Open); + return; + } + foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.Loaded)) ChangeUserState(u.UserID, MultiplayerUserState.Playing); @@ -141,11 +153,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay)) ChangeUserState(u.UserID, MultiplayerUserState.Results); - ChangeRoomState(MultiplayerRoomState.Open); + ChangeRoomState(MultiplayerRoomState.Open); ((IMultiplayerClient)this).ResultsReady(); - finishCurrentItem().Wait(); + FinishCurrentItem().WaitSafely(); } break; @@ -169,6 +181,7 @@ namespace osu.Game.Tests.Visual.Multiplayer serverSidePlaylist.Clear(); serverSidePlaylist.AddRange(apiRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item))); + lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID); var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { @@ -189,6 +202,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Host = localUser }; + await updatePlaylistOrder(room).ConfigureAwait(false); await updateCurrentItem(room, false).ConfigureAwait(false); RoomSetupAction?.Invoke(room); @@ -203,10 +217,16 @@ namespace osu.Game.Tests.Visual.Multiplayer Debug.Assert(Room != null); // emulate the server sending this after the join room. scheduler required to make sure the join room event is fired first (in Join). - changeMatchType(Room.Settings.MatchType).Wait(); + changeMatchType(Room.Settings.MatchType).WaitSafely(); + + RoomJoined = true; } - protected override Task LeaveRoomInternal() => Task.CompletedTask; + protected override Task LeaveRoomInternal() + { + RoomJoined = false; + return Task.CompletedTask; + } public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); @@ -238,6 +258,11 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task ChangeState(MultiplayerUserState newState) { + Debug.Assert(Room != null); + + if (newState == MultiplayerUserState.Idle && LocalUser?.State == MultiplayerUserState.WaitingForLoad) + return Task.CompletedTask; + ChangeUserState(api.LocalUser.Value.Id, newState); return Task.CompletedTask; } @@ -299,6 +324,16 @@ namespace osu.Game.Tests.Visual.Multiplayer return ((IMultiplayerClient)this).LoadRequested(); } + public override Task AbortGameplay() + { + Debug.Assert(Room != null); + Debug.Assert(LocalUser != null); + + ChangeUserState(LocalUser.UserID, MultiplayerUserState.Idle); + + return Task.CompletedTask; + } + public async Task AddUserPlaylistItem(int userId, MultiplayerPlaylistItem item) { Debug.Assert(Room != null); @@ -308,35 +343,71 @@ namespace osu.Game.Tests.Visual.Multiplayer if (Room.Settings.QueueMode == QueueMode.HostOnly && Room.Host?.UserID != LocalUser?.UserID) throw new InvalidOperationException("Local user is not the room host."); - switch (Room.Settings.QueueMode) - { - case QueueMode.HostOnly: - // In host-only mode, the current item is re-used. - item.ID = currentItem.ID; - item.OwnerID = currentItem.OwnerID; + item.OwnerID = userId; - serverSidePlaylist[currentIndex] = item; - await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); - - // Note: Unlike the server, this is the easiest way to update the current item at this point. - await updateCurrentItem(Room, false).ConfigureAwait(false); - break; - - default: - item.ID = serverSidePlaylist.Last().ID + 1; - item.OwnerID = userId; - - serverSidePlaylist.Add(item); - await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); - - await updateCurrentItem(Room).ConfigureAwait(false); - break; - } + await addItem(item).ConfigureAwait(false); + await updateCurrentItem(Room).ConfigureAwait(false); } public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item); - protected override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) + public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item) + { + Debug.Assert(Room != null); + Debug.Assert(APIRoom != null); + Debug.Assert(currentItem != null); + + item.OwnerID = userId; + + var existingItem = serverSidePlaylist.SingleOrDefault(i => i.ID == item.ID); + + if (existingItem == null) + throw new InvalidOperationException("Attempted to change an item that doesn't exist."); + + if (existingItem.OwnerID != userId && Room.Host?.UserID != LocalUser?.UserID) + throw new InvalidOperationException("Attempted to change an item which is not owned by the user."); + + if (existingItem.Expired) + throw new InvalidOperationException("Attempted to change an item which has already been played."); + + // Ensure the playlist order doesn't change. + item.PlaylistOrder = existingItem.PlaylistOrder; + + serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item; + + await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); + } + + public override Task EditPlaylistItem(MultiplayerPlaylistItem item) => EditUserPlaylistItem(api.LocalUser.Value.OnlineID, item); + + public async Task RemoveUserPlaylistItem(int userId, long playlistItemId) + { + Debug.Assert(Room != null); + Debug.Assert(APIRoom != null); + + var item = serverSidePlaylist.Find(i => i.ID == playlistItemId); + + if (item == null) + throw new InvalidOperationException("Item does not exist in the room."); + + if (item == currentItem) + throw new InvalidOperationException("The room's current item cannot be removed."); + + if (item.OwnerID != userId) + throw new InvalidOperationException("Attempted to remove an item which is not owned by the user."); + + if (item.Expired) + throw new InvalidOperationException("Attempted to remove an item which has already been played."); + + serverSidePlaylist.Remove(item); + await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false); + + await updateCurrentItem(Room).ConfigureAwait(false); + } + + public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, playlistItemId); + + public override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) { IBeatmapSetInfo? set = roomManager.ServerSideRooms.SelectMany(r => r.Playlist) .FirstOrDefault(p => p.BeatmapID == beatmapId)?.Beatmap.Value.BeatmapSet @@ -385,11 +456,11 @@ namespace osu.Game.Tests.Visual.Multiplayer if (newMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); - // When changing modes, items could have been added (above) or the queueing order could have changed. + await updatePlaylistOrder(Room).ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); } - private async Task finishCurrentItem() + public async Task FinishCurrentItem() { Debug.Assert(Room != null); Debug.Assert(APIRoom != null); @@ -397,10 +468,13 @@ namespace osu.Game.Tests.Visual.Multiplayer // Expire the current playlist item. currentItem.Expired = true; + currentItem.PlayedAt = DateTimeOffset.Now; + await ((IMultiplayerClient)this).PlaylistItemChanged(currentItem).ConfigureAwait(false); + await updatePlaylistOrder(Room).ConfigureAwait(false); // In host-only mode, a duplicate playlist item will be used for the next round. - if (Room.Settings.QueueMode == QueueMode.HostOnly) + if (Room.Settings.QueueMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); @@ -408,47 +482,93 @@ namespace osu.Game.Tests.Visual.Multiplayer private async Task duplicateCurrentItem() { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); Debug.Assert(currentItem != null); - var newItem = new MultiplayerPlaylistItem + await addItem(new MultiplayerPlaylistItem { - ID = serverSidePlaylist.Last().ID + 1, BeatmapID = currentItem.BeatmapID, BeatmapChecksum = currentItem.BeatmapChecksum, RulesetID = currentItem.RulesetID, RequiredMods = currentItem.RequiredMods, AllowedMods = currentItem.AllowedMods - }; - - serverSidePlaylist.Add(newItem); - await ((IMultiplayerClient)this).PlaylistItemAdded(newItem).ConfigureAwait(false); + }).ConfigureAwait(false); } + private async Task addItem(MultiplayerPlaylistItem item) + { + Debug.Assert(Room != null); + + item.ID = ++lastPlaylistItemId; + + serverSidePlaylist.Add(item); + await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); + + await updatePlaylistOrder(Room).ConfigureAwait(false); + } + + private IEnumerable upcomingItems => serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder); + private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true) { - MultiplayerPlaylistItem newItem; + // Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item. + MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First(); + + currentIndex = serverSidePlaylist.IndexOf(nextItem); + + long lastItem = room.Settings.PlaylistItemId; + room.Settings.PlaylistItemId = nextItem.ID; + + if (notify && nextItem.ID != lastItem) + await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + } + + private async Task updatePlaylistOrder(MultiplayerRoom room) + { + List orderedActiveItems; switch (room.Settings.QueueMode) { default: - // Pick the single non-expired playlist item. - newItem = serverSidePlaylist.FirstOrDefault(i => !i.Expired) ?? serverSidePlaylist.Last(); + orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList(); break; case QueueMode.AllPlayersRoundRobin: - // Group playlist items by (user_id -> count_expired), and select the first available playlist item from a user that has available beatmaps where count_expired is the lowest. - throw new NotImplementedException(); + var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>(); + + // Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items. + foreach (var group in serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID)) + { + int priority = 0; + itemsByPriority.AddRange(group.Select(item => (item, priority++))); + } + + orderedActiveItems = itemsByPriority + // Order by each user's priority. + .OrderBy(i => i.priority) + // Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering. + // Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained, + // then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first). + .ThenBy(i => i.item.PlaylistOrder) + // If there are still ties (normally shouldn't happen), break ties by making items added earlier go first. + // This could happen if e.g. the item orders get reset. + .ThenBy(i => i.item.ID) + .Select(i => i.item) + .ToList(); + + break; } - currentIndex = serverSidePlaylist.IndexOf(newItem); + for (int i = 0; i < orderedActiveItems.Count; i++) + { + var item = orderedActiveItems[i]; - long lastItem = room.Settings.PlaylistItemId; - room.Settings.PlaylistItemId = newItem.ID; + if (item.PlaylistOrder == i) + continue; - if (notify && newItem.ID != lastItem) - await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + item.PlaylistOrder = (ushort)i; + + await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); + } } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index a1f010f082..296db3152d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -17,8 +17,6 @@ namespace osu.Game.Tests.Visual.Multiplayer /// public class TestMultiplayerRoomManager : MultiplayerRoomManager { - public bool RoomJoined { get; private set; } - private readonly TestRoomRequestsHandler requestsHandler; public TestMultiplayerRoomManager(TestRoomRequestsHandler requestsHandler) @@ -29,28 +27,10 @@ namespace osu.Game.Tests.Visual.Multiplayer public IReadOnlyList ServerSideRooms => requestsHandler.ServerSideRooms; public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) - { - base.CreateRoom(room, r => - { - onSuccess?.Invoke(r); - RoomJoined = true; - }, onError); - } + => base.CreateRoom(room, r => onSuccess?.Invoke(r), onError); public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) - { - base.JoinRoom(room, password, r => - { - onSuccess?.Invoke(r); - RoomJoined = true; - }, onError); - } - - public override void PartRoom() - { - base.PartRoom(); - RoomJoined = false; - } + => base.JoinRoom(room, password, r => onSuccess?.Invoke(r), onError); /// /// Adds a room to a local "server-side" list that's returned when a is fired. diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index a4586dea12..520f2c4585 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -4,9 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Scoring; @@ -89,15 +87,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true)); return true; - case GetBeatmapSetRequest getBeatmapSetRequest: - var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type); - onlineReq.Success += res => getBeatmapSetRequest.TriggerSuccess(res); - onlineReq.Failure += e => getBeatmapSetRequest.TriggerFailure(e); - - // Get the online API from the game's dependencies. - game.Dependencies.Get().Queue(onlineReq); - return true; - case CreateRoomScoreRequest createRoomScoreRequest: createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); return true; diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 752794d25a..b7aa8af4aa 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -1,9 +1,12 @@ // 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.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; @@ -11,6 +14,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual { @@ -115,6 +119,25 @@ namespace osu.Game.Tests.Visual }); } + /// + /// Wait for a button to become enabled, then click it. + /// + /// + protected void ClickButtonWhenEnabled() + where T : Drawable + { + if (typeof(T) == typeof(Button)) + AddUntilStep($"wait for {typeof(T).Name} enabled", () => (this.ChildrenOfType().Single() as Button)?.Enabled.Value == true); + else + AddUntilStep($"wait for {typeof(T).Name} enabled", () => this.ChildrenOfType().Single().ChildrenOfType