diff --git a/.editorconfig b/.editorconfig index a5f7795882..0cdf3b92d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -194,4 +194,7 @@ dotnet_diagnostic.IDE0068.severity = none dotnet_diagnostic.IDE0069.severity = none #Disable operator overloads requiring alternate named methods -dotnet_diagnostic.CA2225.severity = none \ No newline at end of file +dotnet_diagnostic.CA2225.severity = none + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/01-bug-issues.md b/.github/ISSUE_TEMPLATE/01-bug-issues.md index 0b80ce44dd..6050036cbf 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-issues.md +++ b/.github/ISSUE_TEMPLATE/01-bug-issues.md @@ -13,4 +13,6 @@ about: Issues regarding encountered bugs. *please attach logs here, which are located at:* - `%AppData%/osu/logs` *(on Windows),* - `~/.local/share/osu/logs` *(on Linux & macOS).* +- `Android/Data/sh.ppy.osulazer/logs` *(on Android)*, +- on iOS they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) --> diff --git a/.github/ISSUE_TEMPLATE/02-crash-issues.md b/.github/ISSUE_TEMPLATE/02-crash-issues.md index ada8de73c0..04170312d1 100644 --- a/.github/ISSUE_TEMPLATE/02-crash-issues.md +++ b/.github/ISSUE_TEMPLATE/02-crash-issues.md @@ -13,6 +13,8 @@ about: Issues regarding crashes or permanent freezes. *please attach logs here, which are located at:* - `%AppData%/osu/logs` *(on Windows),* - `~/.local/share/osu/logs` *(on Linux & macOS).* +- `Android/Data/sh.ppy.osulazer/logs` *(on Android)*, +- on iOS they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) --> **Computer Specifications:** diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 47839608c9..46c50dbfa2 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -7,3 +7,4 @@ M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900) T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods. T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods. +M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. \ No newline at end of file diff --git a/CodeAnalysis/osu.ruleset b/CodeAnalysis/osu.ruleset index d497365f87..6a99e230d1 100644 --- a/CodeAnalysis/osu.ruleset +++ b/CodeAnalysis/osu.ruleset @@ -30,7 +30,7 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index 8e0693f8d5..a91d423043 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ - + $(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset diff --git a/README.md b/README.md index efca075042..e09b4d86a5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # osu! [![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) -[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)]() +[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest) [![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) [![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) diff --git a/osu.Android.props b/osu.Android.props index 9ad5946311..e0392bd687 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 788e5f82be..cffcea22c2 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -17,10 +17,10 @@ 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)] + [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)] [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.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream" })] + [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed" })] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })] public class OsuGameActivity : AndroidGameActivity { @@ -100,15 +100,15 @@ namespace osu.Android // copy to an arbitrary-access memory stream to be able to proceed with the import. var copy = new MemoryStream(); using (var stream = ContentResolver.OpenInputStream(uri)) - await stream.CopyToAsync(copy); + await stream.CopyToAsync(copy).ConfigureAwait(false); lock (tasks) { tasks.Add(new ImportTask(copy, filename)); } - })); + })).ConfigureAwait(false); - await game.Import(tasks.ToArray()); + await game.Import(tasks.ToArray()).ConfigureAwait(false); }, TaskCreationOptions.LongRunning); } } diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index 63b12fb84b..832d26b0ef 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -105,7 +105,7 @@ namespace osu.Desktop if (privacyMode.Value == DiscordRichPresenceMode.Limited) presence.Assets.LargeImageText = string.Empty; else - presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.Ranks.Global > 0 ? $" (rank #{user.Value.Statistics.Ranks.Global:N0})" : string.Empty); + presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty); // update ruleset presence.Assets.SmallImageKey = ruleset.Value.ID <= 3 ? $"mode_{ruleset.Value.ID}" : "mode_custom"; diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index d1515acafa..b2487568ce 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -18,6 +18,7 @@ using osu.Framework.Screens; using osu.Game.Screens.Menu; using osu.Game.Updater; using osu.Desktop.Windows; +using osu.Game.IO; namespace osu.Desktop { @@ -32,7 +33,7 @@ namespace osu.Desktop noVersionOverlay = args?.Any(a => a == "--no-version-overlay") ?? false; } - public override Storage GetStorageForStableInstall() + public override StableStorage GetStorageForStableInstall() { try { @@ -40,7 +41,7 @@ namespace osu.Desktop { string stablePath = getStableInstallPath(); if (!string.IsNullOrEmpty(stablePath)) - return new DesktopStorage(stablePath, desktopHost); + return new StableStorage(stablePath, desktopHost); } } catch (Exception) @@ -135,24 +136,12 @@ namespace osu.Desktop var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); - switch (host.Window) - { - // Legacy osuTK DesktopGameWindow - case OsuTKDesktopWindow desktopGameWindow: - desktopGameWindow.CursorState |= CursorState.Hidden; - desktopGameWindow.SetIconFromStream(iconStream); - desktopGameWindow.Title = Name; - desktopGameWindow.FileDrop += (_, e) => fileDrop(e.FileNames); - break; + var desktopWindow = (SDL2DesktopWindow)host.Window; - // SDL2 DesktopWindow - case SDL2DesktopWindow desktopWindow: - desktopWindow.CursorState |= CursorState.Hidden; - desktopWindow.SetIconFromStream(iconStream); - desktopWindow.Title = Name; - desktopWindow.DragDrop += f => fileDrop(new[] { f }); - break; - } + desktopWindow.CursorState |= CursorState.Hidden; + desktopWindow.SetIconFromStream(iconStream); + desktopWindow.Title = Name; + desktopWindow.DragDrop += f => fileDrop(new[] { f }); } private void fileDrop(string[] filePaths) diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 6ca7079654..d06c4b6746 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -22,9 +22,8 @@ namespace osu.Desktop { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; - bool useOsuTK = args.Contains("--tk"); - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true, useOsuTK: useOsuTK)) + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) { host.ExceptionThrown += handleException; diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 71f9fafe57..47cd39dc5a 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -42,7 +42,7 @@ namespace osu.Desktop.Updater Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); } - protected override async Task PerformUpdateCheck() => await checkForUpdateAsync(); + protected override async Task PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false); private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) { @@ -51,9 +51,9 @@ namespace osu.Desktop.Updater try { - updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true); + updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true).ConfigureAwait(false); - var info = await updateManager.CheckForUpdate(!useDeltaPatching); + var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false); if (info.ReleasesToApply.Count == 0) { @@ -79,12 +79,12 @@ namespace osu.Desktop.Updater try { - await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f); + await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f).ConfigureAwait(false); notification.Progress = 0; notification.Text = @"Installing update..."; - await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f); + await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f).ConfigureAwait(false); notification.State = ProgressNotificationState.Completed; updatePending = true; @@ -97,7 +97,7 @@ namespace osu.Desktop.Updater // could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959) // try again without deltas. - await checkForUpdateAsync(false, notification); + await checkForUpdateAsync(false, notification).ConfigureAwait(false); scheduleRecheck = false; } else @@ -116,7 +116,7 @@ namespace osu.Desktop.Updater if (scheduleRecheck) { // check again in 30 minutes. - Scheduler.AddDelayed(async () => await checkForUpdateAsync(), 60000 * 30); + Scheduler.AddDelayed(async () => await checkForUpdateAsync().ConfigureAwait(false), 60000 * 30); } } diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 4554f8b83a..d9d23dea6b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,16 +24,13 @@ + - - - + + + - - - - - + diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 7805bfcefc..ea43d9a54c 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -8,7 +8,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index ee416e5a38..f4ee3f5a42 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Difficulty; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Difficulty; using osu.Game.Tests.Beatmaps; @@ -17,6 +18,10 @@ namespace osu.Game.Rulesets.Catch.Tests public void Test(double expected, string name) => base.Test(expected, name); + [TestCase(5.0565038923984691d, "diffcalc-test")] + public void TestClockRateAdjusted(double expected, string name) + => Test(expected, name, new CatchModDoubleTime()); + protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset(), beatmap); protected override Ruleset CreateRuleset() => new CatchRuleset(); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs index f15da29993..1248409b2a 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Tests [BackgroundDependencyLoader] private void load() { - LocalConfig.Set(OsuSetting.IncreaseFirstObjectVisibility, false); + LocalConfig.SetValue(OsuSetting.IncreaseFirstObjectVisibility, false); } [Test] diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index e8bb57cdf3..48efd73222 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -202,7 +202,7 @@ namespace osu.Game.Rulesets.Catch.Tests public void TestHitLightingColour() { var fruitColour = SkinConfiguration.DefaultComboColours[1]; - AddStep("enable hit lighting", () => config.Set(OsuSetting.HitLighting, true)); + AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true)); AddStep("catch fruit", () => attemptCatch(new Fruit())); AddAssert("correct hit lighting colour", () => catcher.ChildrenOfType().First()?.ObjectColour == fruitColour); @@ -211,7 +211,7 @@ namespace osu.Game.Rulesets.Catch.Tests [Test] public void TestHitLightingDisabled() { - AddStep("disable hit lighting", () => config.Set(OsuSetting.HitLighting, false)); + AddStep("disable hit lighting", () => config.SetValue(OsuSetting.HitLighting, false)); AddStep("catch fruit", () => attemptCatch(new Fruit())); AddAssert("no hit lighting", () => !catcher.ChildrenOfType().Any()); } diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index 54fddc297e..42f70151ac 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -2,10 +2,10 @@ - - + + - + WinExe diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 0a817eca0d..f4ddbd3021 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using System; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Catch.Skinning.Legacy; using osu.Game.Skinning; @@ -50,40 +51,40 @@ namespace osu.Game.Rulesets.Catch public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new CatchModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new CatchModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new CatchModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new CatchModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new CatchModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new CatchModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new CatchModEasy(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new CatchModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new CatchModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new CatchModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new CatchModHidden(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new CatchModNoFail(); - if (mods.HasFlag(LegacyMods.Relax)) + if (mods.HasFlagFast(LegacyMods.Relax)) yield return new CatchModRelax(); } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index a317ef252d..10aae70722 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f; @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty return new Skill[] { - new Movement(halfCatcherWidth), + new Movement(mods, halfCatcherWidth), }; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index e679231638..9ad719be1a 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -5,6 +5,7 @@ using System; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Difficulty.Skills { @@ -25,7 +26,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills private float lastDistanceMoved; private double lastStrainTime; - public Movement(float halfCatcherWidth) + public Movement(Mod[] mods, float halfCatcherWidth) + : base(mods) { HalfCatcherWidth = halfCatcherWidth; } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs index 692e63fa69..e1eceea606 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.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.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Replays; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModAutoplay : ModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, Replay = new CatchAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs index 3bc1ee5bf5..d53d019e90 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.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.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Replays; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModCinema : ModCinema { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, Replay = new CatchAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 438d17dbc5..5f1736450a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Catch.Mods public class CatchModDifficultyAdjust : ModDifficultyAdjust { [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] - public BindableNumber CircleSize { get; } = new BindableFloat + public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 1, @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Mods }; [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] - public BindableNumber ApproachRate { get; } = new BindableFloat + public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 1, @@ -31,6 +31,14 @@ namespace osu.Game.Rulesets.Catch.Mods Value = 5, }; + protected override void ApplyLimits(bool extended) + { + base.ApplyLimits(extended); + + CircleSize.MaxValue = extended ? 11 : 10; + ApproachRate.MaxValue = extended ? 11 : 10; + } + public override string SettingDescription { get diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index 32e8ab5da7..64ded8e94f 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -45,6 +45,11 @@ namespace osu.Game.Rulesets.Catch.Replays float positionChange = Math.Abs(lastPosition - h.EffectiveX); double timeAvailable = h.StartTime - lastTime; + if (timeAvailable < 0) + { + return; + } + // So we can either make it there without a dash or not. // If positionChange is 0, we don't need to move, so speedRequired should also be 0 (could be NaN if timeAvailable is 0 too) // The case where positionChange > 0 and timeAvailable == 0 results in PositiveInfinity which provides expected beheaviour. diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs index 7308d6b499..8d8ee49af7 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs @@ -29,4 +29,3 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default } } } - diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs index d160956a6e..c8895f32f4 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs @@ -19,4 +19,3 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default } } } - diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index a25551f854..09ca04be8a 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mania.Difficulty; +using osu.Game.Rulesets.Mania.Mods; using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Mania.Tests @@ -17,6 +18,10 @@ namespace osu.Game.Rulesets.Mania.Tests public void Test(double expected, string name) => base.Test(expected, name); + [TestCase(2.7646128945056723d, "diffcalc-test")] + public void TestClockRateAdjusted(double expected, string name) + => Test(expected, name, new ManiaModDoubleTime()); + protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset(), beatmap); protected override Ruleset CreateRuleset() => new ManiaRuleset(); diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs new file mode 100644 index 0000000000..60363aaeef --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.UI.Scrolling.Algorithms; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModConstantSpeed : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [Test] + public void TestConstantScroll() => CreateModTest(new ModTestData + { + Mod = new ManiaModConstantSpeed(), + PassCondition = () => + { + var hitObject = Player.ChildrenOfType().FirstOrDefault(); + return hitObject?.Dependencies.Get().Algorithm is ConstantScrollAlgorithm; + } + }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs index 6b8f5d5d9d..706268e478 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -97,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.Tests } private bool verifyAnchors(DrawableHitObject hitObject, Anchor expectedAnchor) - => hitObject.Anchor.HasFlag(expectedAnchor) && hitObject.Origin.HasFlag(expectedAnchor); + => hitObject.Anchor.HasFlagFast(expectedAnchor) && hitObject.Origin.HasFlagFast(expectedAnchor); private bool verifyAnchors(DrawableHoldNote holdNote, Anchor expectedAnchor) => verifyAnchors((DrawableHitObject)holdNote, expectedAnchor) && holdNote.NestedHitObjects.All(n => verifyAnchors(n, expectedAnchor)); diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index d55b4fe08a..e51b20c9fe 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -2,10 +2,10 @@ - - + + - + WinExe diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 30d33de06e..c81710ed18 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.MathUtils; @@ -141,7 +142,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 6.5) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.78, 0.3, 0); return generateNRandomNotes(StartTime, 0.85, 0.36, 0.03); @@ -149,7 +150,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 4) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.43, 0.08, 0); return generateNRandomNotes(StartTime, 0.56, 0.18, 0); @@ -157,13 +158,13 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 2.5) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.3, 0, 0); return generateNRandomNotes(StartTime, 0.37, 0.08, 0); } - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.17, 0, 0); return generateNRandomNotes(StartTime, 0.27, 0, 0); @@ -221,7 +222,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var pattern = new Pattern(); int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) + if (convertType.HasFlagFast(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) nextColumn = FindAvailableColumn(nextColumn, PreviousPattern); int lastColumn = nextColumn; @@ -373,7 +374,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy static bool isDoubleSample(HitSampleInfo sample) => sample.Name == HitSampleInfo.HIT_CLAP || sample.Name == HitSampleInfo.HIT_FINISH; - bool canGenerateTwoNotes = !convertType.HasFlag(PatternType.LowProbability); + bool canGenerateTwoNotes = !convertType.HasFlagFast(PatternType.LowProbability); canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(StartTime).Any(isDoubleSample); if (canGenerateTwoNotes) @@ -406,7 +407,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int endTime = startTime + SegmentDuration * SpanCount; int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) + if (convertType.HasFlagFast(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) nextColumn = FindAvailableColumn(nextColumn, PreviousPattern); for (int i = 0; i < columnRepeat; i++) @@ -435,7 +436,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var pattern = new Pattern(); int holdColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) + if (convertType.HasFlagFast(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) holdColumn = FindAvailableColumn(holdColumn, PreviousPattern); // Create the hold note diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs index bc4ab55767..54c37e9742 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osuTK; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -78,7 +79,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy else convertType |= PatternType.LowProbability; - if (!convertType.HasFlag(PatternType.KeepSingle)) + if (!convertType.HasFlagFast(PatternType.KeepSingle)) { if (HitObject.Samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH) && TotalColumns != 8) convertType |= PatternType.Mirror; @@ -101,7 +102,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int lastColumn = PreviousPattern.HitObjects.FirstOrDefault()?.Column ?? 0; - if (convertType.HasFlag(PatternType.Reverse) && PreviousPattern.HitObjects.Any()) + if (convertType.HasFlagFast(PatternType.Reverse) && PreviousPattern.HitObjects.Any()) { // Generate a new pattern by copying the last hit objects in reverse-column order for (int i = RandomStart; i < TotalColumns; i++) @@ -113,11 +114,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return pattern; } - if (convertType.HasFlag(PatternType.Cycle) && PreviousPattern.HitObjects.Count() == 1 - // If we convert to 7K + 1, let's not overload the special key - && (TotalColumns != 8 || lastColumn != 0) - // Make sure the last column was not the centre column - && (TotalColumns % 2 == 0 || lastColumn != TotalColumns / 2)) + if (convertType.HasFlagFast(PatternType.Cycle) && PreviousPattern.HitObjects.Count() == 1 + // If we convert to 7K + 1, let's not overload the special key + && (TotalColumns != 8 || lastColumn != 0) + // Make sure the last column was not the centre column + && (TotalColumns % 2 == 0 || lastColumn != TotalColumns / 2)) { // Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object) int column = RandomStart + TotalColumns - lastColumn - 1; @@ -126,7 +127,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return pattern; } - if (convertType.HasFlag(PatternType.ForceStack) && PreviousPattern.HitObjects.Any()) + if (convertType.HasFlagFast(PatternType.ForceStack) && PreviousPattern.HitObjects.Any()) { // Generate a new pattern by placing on the already filled columns for (int i = RandomStart; i < TotalColumns; i++) @@ -140,7 +141,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (PreviousPattern.HitObjects.Count() == 1) { - if (convertType.HasFlag(PatternType.Stair)) + if (convertType.HasFlagFast(PatternType.Stair)) { // Generate a new pattern by placing on the next column, cycling back to the start if there is no "next" int targetColumn = lastColumn + 1; @@ -151,7 +152,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return pattern; } - if (convertType.HasFlag(PatternType.ReverseStair)) + if (convertType.HasFlagFast(PatternType.ReverseStair)) { // Generate a new pattern by placing on the previous column, cycling back to the end if there is no "previous" int targetColumn = lastColumn - 1; @@ -163,10 +164,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy } } - if (convertType.HasFlag(PatternType.KeepSingle)) + if (convertType.HasFlagFast(PatternType.KeepSingle)) return generateRandomNotes(1); - if (convertType.HasFlag(PatternType.Mirror)) + if (convertType.HasFlagFast(PatternType.Mirror)) { if (ConversionDifficulty > 6.5) return generateRandomPatternWithMirrored(0.12, 0.38, 0.12); @@ -178,7 +179,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 6.5) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateRandomPattern(0.78, 0.42, 0, 0); return generateRandomPattern(1, 0.62, 0, 0); @@ -186,7 +187,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 4) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateRandomPattern(0.35, 0.08, 0, 0); return generateRandomPattern(0.52, 0.15, 0, 0); @@ -194,7 +195,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 2) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateRandomPattern(0.18, 0, 0, 0); return generateRandomPattern(0.45, 0, 0, 0); @@ -207,9 +208,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy foreach (var obj in p.HitObjects) { - if (convertType.HasFlag(PatternType.Stair) && obj.Column == TotalColumns - 1) + if (convertType.HasFlagFast(PatternType.Stair) && obj.Column == TotalColumns - 1) StairType = PatternType.ReverseStair; - if (convertType.HasFlag(PatternType.ReverseStair) && obj.Column == RandomStart) + if (convertType.HasFlagFast(PatternType.ReverseStair) && obj.Column == RandomStart) StairType = PatternType.Stair; } @@ -229,7 +230,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { var pattern = new Pattern(); - bool allowStacking = !convertType.HasFlag(PatternType.ForceNotStack); + bool allowStacking = !convertType.HasFlagFast(PatternType.ForceNotStack); if (!allowStacking) noteCount = Math.Min(noteCount, TotalColumns - RandomStart - PreviousPattern.ColumnWithObjects); @@ -249,7 +250,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int getNextColumn(int last) { - if (convertType.HasFlag(PatternType.Gathered)) + if (convertType.HasFlagFast(PatternType.Gathered)) { last++; if (last == TotalColumns) @@ -296,7 +297,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The containing the hit objects. private Pattern generateRandomPatternWithMirrored(double centreProbability, double p2, double p3) { - if (convertType.HasFlag(PatternType.ForceNotStack)) + if (convertType.HasFlagFast(PatternType.ForceNotStack)) return generateRandomPattern(1 / 2f + p2 / 2, p2, (p2 + p3) / 2, p3); var pattern = new Pattern(); diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 756f2b7b2f..39d0f4bae4 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -20,8 +20,8 @@ namespace osu.Game.Rulesets.Mania.Configuration { base.InitialiseDefaults(); - Set(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); - Set(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); + SetDefault(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); + SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index ade830764d..8c0b9ed8b7 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -68,9 +68,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. protected override IEnumerable SortObjects(IEnumerable input) => input; - protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { - new Strain(((ManiaBeatmap)beatmap).TotalColumns) + new Strain(mods, ((ManiaBeatmap)beatmap).TotalColumns) }; protected override Mod[] DifficultyAdjustmentMods diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index 7ebc1ff752..d6ea58ee78 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -6,6 +6,7 @@ using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mania.Difficulty.Skills @@ -24,7 +25,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills private double individualStrain; private double overallStrain; - public Strain(int totalColumns) + public Strain(Mod[] mods, int totalColumns) + : base(mods) { holdEndTimes = new double[totalColumns]; individualStrains = new double[totalColumns]; diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 50629f41a9..2689ed4112 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -45,6 +45,7 @@ namespace osu.Game.Rulesets.Mania.Edit int minColumn = int.MaxValue; int maxColumn = int.MinValue; + // find min/max in an initial pass before actually performing the movement. foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) { if (obj.Column < minColumn) @@ -55,8 +56,11 @@ namespace osu.Game.Rulesets.Mania.Edit columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); - foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) - obj.Column += columnDelta; + EditorBeatmap.PerformOnSelection(h => + { + if (h is ManiaHitObject maniaObj) + maniaObj.Column += columnDelta; + }); } } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 59c766fd84..d624e094ad 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -59,76 +60,76 @@ namespace osu.Game.Rulesets.Mania public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new ManiaModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new ManiaModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new ManiaModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new ManiaModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new ManiaModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new ManiaModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new ManiaModEasy(); - if (mods.HasFlag(LegacyMods.FadeIn)) + if (mods.HasFlagFast(LegacyMods.FadeIn)) yield return new ManiaModFadeIn(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new ManiaModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new ManiaModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new ManiaModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new ManiaModHidden(); - if (mods.HasFlag(LegacyMods.Key1)) + if (mods.HasFlagFast(LegacyMods.Key1)) yield return new ManiaModKey1(); - if (mods.HasFlag(LegacyMods.Key2)) + if (mods.HasFlagFast(LegacyMods.Key2)) yield return new ManiaModKey2(); - if (mods.HasFlag(LegacyMods.Key3)) + if (mods.HasFlagFast(LegacyMods.Key3)) yield return new ManiaModKey3(); - if (mods.HasFlag(LegacyMods.Key4)) + if (mods.HasFlagFast(LegacyMods.Key4)) yield return new ManiaModKey4(); - if (mods.HasFlag(LegacyMods.Key5)) + if (mods.HasFlagFast(LegacyMods.Key5)) yield return new ManiaModKey5(); - if (mods.HasFlag(LegacyMods.Key6)) + if (mods.HasFlagFast(LegacyMods.Key6)) yield return new ManiaModKey6(); - if (mods.HasFlag(LegacyMods.Key7)) + if (mods.HasFlagFast(LegacyMods.Key7)) yield return new ManiaModKey7(); - if (mods.HasFlag(LegacyMods.Key8)) + if (mods.HasFlagFast(LegacyMods.Key8)) yield return new ManiaModKey8(); - if (mods.HasFlag(LegacyMods.Key9)) + if (mods.HasFlagFast(LegacyMods.Key9)) yield return new ManiaModKey9(); - if (mods.HasFlag(LegacyMods.KeyCoop)) + if (mods.HasFlagFast(LegacyMods.KeyCoop)) yield return new ManiaModDualStages(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new ManiaModNoFail(); - if (mods.HasFlag(LegacyMods.Random)) + if (mods.HasFlagFast(LegacyMods.Random)) yield return new ManiaModRandom(); - if (mods.HasFlag(LegacyMods.Mirror)) + if (mods.HasFlagFast(LegacyMods.Mirror)) yield return new ManiaModMirror(); } @@ -238,6 +239,7 @@ namespace osu.Game.Rulesets.Mania new ManiaModMirror(), new ManiaModDifficultyAdjust(), new ManiaModInvert(), + new ManiaModConstantSpeed() }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs index c05e979e9a..105d88129c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.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.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModAutoplay : ModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs index 02c1fc1b79..064c55ed8d 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.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.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModCinema : ModCinema { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs new file mode 100644 index 0000000000..078394b1d8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -0,0 +1,35 @@ +// 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.Sprites; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModConstantSpeed : Mod, IApplicableToDrawableRuleset + { + public override string Name => "Constant Speed"; + + public override string Acronym => "CS"; + + public override double ScoreMultiplier => 1; + + public override string Description => "No more tricky speed changes!"; + + public override IconUsage? Icon => FontAwesome.Solid.Equals; + + public override ModType Type => ModType.Conversion; + + public override bool Ranked => false; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + maniaRuleset.ScrollMethod = ScrollVisualisationMethod.Constant; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index cbdcd49c5b..f80c9e1f7c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -1,18 +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 osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; +using System; +using System.Linq; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModFadeIn : ManiaModHidden + public class ManiaModFadeIn : ManiaModPlayfieldCover { public override string Name => "Fade In"; public override string Acronym => "FI"; - public override IconUsage? Icon => OsuIcon.ModHidden; public override string Description => @"Keys appear out of nowhere!"; + public override double ScoreMultiplier => 1; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModHidden)).ToArray(); protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index 4bdb15526f..e3ac624a6e 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -3,43 +3,17 @@ using System; using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModHidden : ModHidden, IApplicableToDrawableRuleset + public class ManiaModHidden : ManiaModPlayfieldCover { public override string Description => @"Keys fade out before you hit them!"; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; - /// - /// The direction in which the cover should expand. - /// - protected virtual CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModFadeIn)).ToArray(); - public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; - - foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) - { - HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; - Container hocParent = (Container)hoc.Parent; - - hocParent.Remove(hoc); - hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => - { - c.RelativeSizeAxes = Axes.Both; - c.Direction = ExpandDirection; - c.Coverage = 0.5f; - })); - } - } + protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs new file mode 100644 index 0000000000..87501d07a5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.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; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public abstract class ManiaModPlayfieldCover : ModHidden, IApplicableToDrawableRuleset + { + public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + /// + /// The direction in which the cover should expand. + /// + protected abstract CoverExpandDirection ExpandDirection { get; } + + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; + + foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) + { + HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; + Container hocParent = (Container)hoc.Parent; + + hocParent.Remove(hoc); + hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => + { + c.RelativeSizeAxes = Axes.Both; + c.Direction = ExpandDirection; + c.Coverage = 0.5f; + })); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 7e2a8823b6..cbbbacfe19 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -140,11 +140,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return animation == null ? null : new LegacyManiaJudgementPiece(result, animation); } - public override SampleChannel GetSample(ISampleInfo sampleInfo) + public override Sample GetSample(ISampleInfo sampleInfo) { // layered hit sounds never play in mania if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) - return new SampleChannelVirtual(); + return new SampleVirtual(); return Source.GetSample(sampleInfo); } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 941ac9816c..4ee060e91e 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.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.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -11,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; @@ -49,6 +51,22 @@ namespace osu.Game.Rulesets.Mania.UI protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; + public ScrollVisualisationMethod ScrollMethod + { + get => scrollMethod; + set + { + if (IsLoaded) + throw new InvalidOperationException($"Can't alter {nameof(ScrollMethod)} after ruleset is already loaded"); + + scrollMethod = value; + } + } + + private ScrollVisualisationMethod scrollMethod = ScrollVisualisationMethod.Sequential; + + protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod; + private readonly Bindable configDirection = new Bindable(); private readonly Bindable configTimeRange = new BindableDouble(); diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs new file mode 100644 index 0000000000..856b6554b9 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Osu.UI; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModAutoplay : OsuModTestScene + { + [Test] + public void TestSpmUnaffectedByRateAdjust() + => runSpmTest(new OsuModDaycore + { + SpeedChange = { Value = 0.88 } + }); + + [Test] + public void TestSpmUnaffectedByTimeRamp() + => runSpmTest(new ModWindUp + { + InitialRate = { Value = 0.7 }, + FinalRate = { Value = 1.3 } + }); + + private void runSpmTest(Mod mod) + { + SpinnerSpmCounter spmCounter = null; + + CreateModTest(new ModTestData + { + Autoplay = true, + Mod = mod, + Beatmap = new Beatmap + { + HitObjects = + { + new Spinner + { + Duration = 2000, + Position = OsuPlayfield.BASE_SIZE / 2 + } + } + }, + PassCondition = () => Player.ScoreProcessor.JudgedHits >= 1 + }); + + AddUntilStep("fetch SPM counter", () => + { + spmCounter = this.ChildrenOfType().SingleOrDefault(); + return spmCounter != null; + }); + + AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCounter.SpinsPerMinute, 477, 5)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 85a41137d4..a365ea10d4 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu.Difficulty; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Osu.Tests @@ -19,6 +20,11 @@ namespace osu.Game.Rulesets.Osu.Tests public void Test(double expected, string name) => base.Test(expected, name); + [TestCase(8.6228371119393064d, "diffcalc-test")] + [TestCase(1.2864585434597433d, "zero-length-sliders")] + public void TestClockRateAdjusted(double expected, string name) + => Test(expected, name, new OsuModDoubleTime()); + protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset(), beatmap); protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index fefe983f97..e2d9f144c0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Tests return null; } - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index e4158d8f07..4395ca6281 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestHitLightingDisabled() { - AddStep("hit lighting disabled", () => config.Set(OsuSetting.HitLighting, false)); + AddStep("hit lighting disabled", () => config.SetValue(OsuSetting.HitLighting, false)); showResult(HitResult.Great); @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestHitLightingEnabled() { - AddStep("hit lighting enabled", () => config.Set(OsuSetting.HitLighting, true)); + AddStep("hit lighting enabled", () => config.SetValue(OsuSetting.HitLighting, true)); showResult(HitResult.Great); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 461779b185..e3ccf83715 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddSliderStep("circle size", 0f, 10f, 0f, val => { - config.Set(OsuSetting.AutoCursorSize, true); + config.SetValue(OsuSetting.AutoCursorSize, true); gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val; Scheduler.AddOnce(recreate); }); @@ -64,21 +64,21 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(10, 1.5f)] public void TestSizing(int circleSize, float userScale) { - AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddStep($"set user scale to {userScale}", () => config.SetValue(OsuSetting.GameplayCursorSize, userScale)); AddStep($"adjust cs to {circleSize}", () => gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize); - AddStep("turn on autosizing", () => config.Set(OsuSetting.AutoCursorSize, true)); + AddStep("turn on autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, true)); AddStep("load content", loadContent); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize) * userScale); - AddStep("set user scale to 1", () => config.Set(OsuSetting.GameplayCursorSize, 1f)); + AddStep("set user scale to 1", () => config.SetValue(OsuSetting.GameplayCursorSize, 1f)); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize)); - AddStep("turn off autosizing", () => config.Set(OsuSetting.AutoCursorSize, false)); + AddStep("turn off autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, false)); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == 1); - AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddStep($"set user scale to {userScale}", () => config.SetValue(OsuSetting.GameplayCursorSize, userScale)); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == userScale); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs index 39deba2f57..af67ab5839 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs @@ -1,9 +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 System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Replays; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -65,10 +67,10 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestAutoMod : OsuModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, - Replay = new MissingAutoGenerator(beatmap).Generate() + Replay = new MissingAutoGenerator(beatmap, mods).Generate() }; } @@ -76,8 +78,8 @@ namespace osu.Game.Rulesets.Osu.Tests { public new OsuBeatmap Beatmap => (OsuBeatmap)base.Beatmap; - public MissingAutoGenerator(IBeatmap beatmap) - : base(beatmap) + public MissingAutoGenerator(IBeatmap beatmap, IReadOnlyList mods) + : base(beatmap, mods) { } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs new file mode 100644 index 0000000000..77a68b714b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -0,0 +1,491 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneObjectOrderedHitPolicy : RateAdjustedBeatmapTestScene + { + private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss + private const double late_miss_window = 500; // time after +500 is considered a miss + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAtFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAfterFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 + addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100 + } + + /// + /// Tests clicking a future circle after the first circle's start time, while the first circle HAS been judged. + /// + [Test] + public void TestClickSecondCircleAfterFirstCircleTimeWithFirstCircleJudged() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 + addJudgementOffsetAssert(hitObjects[1], -100); // time_second_circle - first_circle_time + } + + /// + /// Tests clicking a future circle after a slider's start time, but hitting all slider ticks. + /// + [Test] + public void TestMissSliderHeadAndHitAllSliderTicks() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); + } + + /// + /// Tests clicking hitting future slider ticks before a circle. + /// + [Test] + public void TestHitSliderTicksBeforeCircle() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(30); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 100, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); + } + + /// + /// Tests clicking a future circle before a spinner. + /// + [Test] + public void TestHitCircleBeforeSpinner() + { + const double time_spinner = 1500; + const double time_circle = 1800; + Vector2 positionCircle = Vector2.Zero; + + var hitObjects = new List + { + new TestSpinner + { + StartTime = time_spinner, + Position = new Vector2(256, 192), + EndTime = time_spinner + 1000, + }, + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + [Test] + public void TestHitSliderHeadBeforeHitCircle() + { + const double time_circle = 1000; + const double time_slider = 1200; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + } + + private void addJudgementAssert(string name, Func hitObject, HitResult result) + { + AddAssert($"{name} judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject()).Type == result); + } + + private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}", + () => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100)); + } + + private ScoreAccessibleReplayPlayer currentPlayer; + private List judgementResults; + + private void performTest(List hitObjects, List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = hitObjects, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 }, + Ruleset = new OsuRuleset().RulesetInfo + }, + }); + + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + + SelectedMods.Value = new[] { new OsuModClassic() }; + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults = new List(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + } + + private class TestHitCircle : HitCircle + { + protected override HitWindows CreateHitWindows() => new TestHitWindows(); + } + + private class TestSlider : Slider + { + public TestSlider() + { + DefaultsApplied += _ => + { + HeadCircle.HitWindows = new TestHitWindows(); + TailCircle.HitWindows = new TestHitWindows(); + + HeadCircle.HitWindows.SetDifficulty(0); + TailCircle.HitWindows.SetDifficulty(0); + }; + } + } + + private class TestSpinner : Spinner + { + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + SpinsRequired = 1; + } + } + + private class TestHitWindows : HitWindows + { + private static readonly DifficultyRange[] ranges = + { + new DifficultyRange(HitResult.Great, 500, 500, 500), + new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window), + }; + + public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss; + + protected override DifficultyRange[] GetRanges() => ranges; + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 10baca438d..56f6fb85fa 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -42,10 +42,10 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep("enable user provider", () => testUserSkin.Enabled = true); - AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); + AddStep("enable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, true)); checkNextHitObject("beatmap"); - AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); + AddStep("disable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, false)); checkNextHitObject("user"); AddStep("disable user provider", () => testUserSkin.Enabled = false); @@ -57,20 +57,20 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep("enable user provider", () => testUserSkin.Enabled = true); - AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); - AddStep("enable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, true)); + AddStep("enable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, true)); + AddStep("enable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, true)); checkNextHitObject("beatmap"); - AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); - AddStep("disable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, false)); + AddStep("enable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, true)); + AddStep("disable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, false)); checkNextHitObject("beatmap"); - AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); - AddStep("enable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, true)); + AddStep("disable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, false)); + AddStep("enable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, true)); checkNextHitObject("user"); - AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); - AddStep("disable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, false)); + AddStep("disable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, false)); + AddStep("disable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, false)); checkNextHitObject("user"); AddStep("disable user provider", () => testUserSkin.Enabled = false); @@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Osu.Tests public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public SampleChannel GetSample(ISampleInfo sampleInfo) => null; + public Sample GetSample(ISampleInfo sampleInfo) => null; public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => default; public IBindable GetConfig(TLookup lookup) => null; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs similarity index 99% rename from osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs rename to osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 296b421a11..177a4f50a1 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -25,7 +25,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene + public class TestSceneStartTimeOrderedHitPolicy : RateAdjustedBeatmapTestScene { private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss private const double late_miss_window = 500; // time after +500 is considered a miss @@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Great); addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 - addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100 + addJudgementOffsetAssert(hitObjects[1], -200); // time_second_circle - first_circle_time - 100 } /// diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index 345c3e6d35..f1f75148ef 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -2,10 +2,10 @@ - - + + - + WinExe diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs index e8272057f3..9589fd576f 100644 --- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs @@ -17,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Configuration protected override void InitialiseDefaults() { base.InitialiseDefaults(); - Set(OsuRulesetSetting.SnakingInSliders, true); - Set(OsuRulesetSetting.SnakingOutSliders, true); - Set(OsuRulesetSetting.ShowCursorTrail, true); - Set(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); + SetDefault(OsuRulesetSetting.SnakingInSliders, true); + SetDefault(OsuRulesetSetting.SnakingOutSliders, true); + SetDefault(OsuRulesetSetting.ShowCursorTrail, true); + SetDefault(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 6a7d76151c..75d6786d95 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -79,10 +79,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { - new Aim(), - new Speed() + new Aim(mods), + new Speed(mods) }; protected override Mod[] DifficultyAdjustmentMods => new Mod[] diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index e74f4933b2..90cba13c7c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -4,6 +4,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -17,6 +18,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private const double angle_bonus_begin = Math.PI / 3; private const double timing_threshold = 107; + public Aim(Mod[] mods) + : base(mods) + { + } + protected override double SkillMultiplier => 26.25; protected override double StrainDecayBase => 0.15; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 01f2fb8dc8..200bc7997d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -4,6 +4,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -27,6 +28,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private const double max_speed_bonus = 45; // ~330BPM private const double speed_balancing_factor = 40; + public Speed(Mod[] mods) + : base(mods) + { + } + protected override double StrainValueOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) 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 e9838de63d..1390675a1a 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -7,11 +7,13 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; 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; @@ -23,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// /// A visualisation of a single in a . /// - public class PathControlPointPiece : BlueprintPiece + public class PathControlPointPiece : BlueprintPiece, IHasTooltip { public Action RequestSelection; @@ -195,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components markerRing.Alpha = IsSelected.Value ? 1 : 0; - Color4 colour = ControlPoint.Type.Value != null ? colours.Red : colours.Yellow; + Color4 colour = getColourFromNodeType(); if (IsHovered || IsSelected.Value) colour = colour.Lighten(1); @@ -203,5 +205,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components marker.Colour = colour; marker.Scale = new Vector2(slider.Scale); } + + private Color4 getColourFromNodeType() + { + if (!(ControlPoint.Type.Value is PathType pathType)) + return colours.Yellow; + + switch (pathType) + { + case PathType.Catmull: + return colours.Seafoam; + + case PathType.Bezier: + return colours.Pink; + + case PathType.PerfectCurve: + return colours.PurpleDark; + + default: + return colours.Red; + } + } + + public string TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs index a0392fe536..dec9cd8622 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { base.Update(); - CirclePiece.UpdateFrom(position == SliderPosition.Start ? HitObject.HeadCircle : HitObject.TailCircle); + CirclePiece.UpdateFrom(position == SliderPosition.Start ? (HitCircle)HitObject.HeadCircle : HitObject.TailCircle); } // Todo: This is temporary, since the slider circle masks don't do anything special yet. In the future they will handle input. diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs index e58aacd86e..9f77175398 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs @@ -38,6 +38,11 @@ namespace osu.Game.Rulesets.Osu.Judgements /// public float RateAdjustedRotation; + /// + /// Time instant at which the spin was started (the first user input which caused an increase in spin). + /// + public double? TimeStarted; + /// /// Time instant at which the spinner has been completed (the user has executed all required spins). /// Will be null if all required spins haven't been completed. diff --git a/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs new file mode 100644 index 0000000000..a088696784 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs @@ -0,0 +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.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class SliderTickJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.LargeTickHit; + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 8c819c4773..aac830801b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Automation; public override string Description => @"Automatic cursor movement - just follow the rhythm."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail), typeof(ModAutoplay) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay) }; public bool PerformFail() => false; @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Mods inputManager.AllowUserCursorMovement = false; // Generate the replay frames the cursor should follow - replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap).Generate().Frames.Cast().ToList(); + replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap, drawableRuleset.Mods).Generate().Frames.Cast().ToList(); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index bea2bbcb32..3b1f271d41 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -16,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Mods { public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, - Replay = new OsuAutoGenerator(beatmap).Generate() + Replay = new OsuAutoGenerator(beatmap, mods).Generate() }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs index 5d9a524577..df06988b70 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -16,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Mods { public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, - Replay = new OsuAutoGenerator(beatmap).Generate() + Replay = new OsuAutoGenerator(beatmap, mods).Generate() }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs new file mode 100644 index 0000000000..5470d0fcb4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -0,0 +1,86 @@ +// 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.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModClassic : Mod, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset + { + public override string Name => "Classic"; + + public override string Acronym => "CL"; + + public override double ScoreMultiplier => 1; + + public override IconUsage? Icon => FontAwesome.Solid.History; + + public override string Description => "Feeling nostalgic?"; + + public override bool Ranked => false; + + public override ModType Type => ModType.Conversion; + + [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] + public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); + + [SettingSource("No slider head movement", "Pins slider heads at their starting position, regardless of time.")] + public Bindable NoSliderHeadMovement { get; } = new BindableBool(true); + + [SettingSource("Apply classic note lock", "Applies note lock to the full hit window.")] + public Bindable ClassicNoteLock { get; } = new BindableBool(true); + + [SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")] + public Bindable FixedFollowCircleHitArea { get; } = new BindableBool(true); + + public void ApplyToHitObject(HitObject hitObject) + { + switch (hitObject) + { + case Slider slider: + slider.OnlyJudgeNestedObjects = !NoSliderHeadAccuracy.Value; + + foreach (var head in slider.NestedHitObjects.OfType()) + head.JudgeAsNormalHitCircle = !NoSliderHeadAccuracy.Value; + + break; + } + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var osuRuleset = (DrawableOsuRuleset)drawableRuleset; + + if (ClassicNoteLock.Value) + osuRuleset.Playfield.HitPolicy = new ObjectOrderedHitPolicy(); + } + + public void ApplyToDrawableHitObjects(IEnumerable drawables) + { + foreach (var obj in drawables) + { + switch (obj) + { + case DrawableSlider slider: + slider.Ball.InputTracksVisualSize = !FixedFollowCircleHitArea.Value; + break; + + case DrawableSliderHead head: + head.TrackFollowCircle = !NoSliderHeadMovement.Value; + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index a638234dbd..1cb25edecf 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModDifficultyAdjust : ModDifficultyAdjust { [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] - public BindableNumber CircleSize { get; } = new BindableFloat + public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods }; [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] - public BindableNumber ApproachRate { get; } = new BindableFloat + public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -31,6 +31,14 @@ namespace osu.Game.Rulesets.Osu.Mods Value = 5, }; + protected override void ApplyLimits(bool extended) + { + base.ApplyLimits(extended); + + CircleSize.MaxValue = extended ? 11 : 10; + ApproachRate.MaxValue = extended ? 11 : 10; + } + public override string SettingDescription { get diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index df0a41455f..4b0939db16 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Skinning.Default; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModTraceable : ModWithVisibilityAdjustment + public class OsuModTraceable : ModWithVisibilityAdjustment { public override string Name => "Traceable"; public override string Acronym => "TC"; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 6e7b1050cb..5541d0e790 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -110,8 +110,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections double startTime = start.GetEndTime(); double duration = end.StartTime - startTime; + // Preempt time can go below 800ms. Normally, this is achieved via the DT mod which uniformly speeds up all animations game wide regardless of AR. + // This uniform speedup is hard to match 1:1, however we can at least make AR>10 (via mods) feel good by extending the upper linear preempt function (see: OsuHitObject). + // Note that this doesn't exactly match the AR>10 visuals as they're classically known, but it feels good. + double preempt = PREEMPT * Math.Min(1, start.TimePreempt / OsuHitObject.PREEMPT_MIN); + fadeOutTime = startTime + fraction * duration; - fadeInTime = fadeOutTime - PREEMPT; + fadeInTime = fadeOutTime - preempt; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 3c0260f5f5..77094f928b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -123,7 +123,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return; } - var result = HitObject.HitWindows.ResultFor(timeOffset); + var result = ResultFor(timeOffset); if (result == HitResult.None || CheckHittable?.Invoke(this, Time.Current) == false) { @@ -146,6 +146,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } + /// + /// Retrieves the for a time offset. + /// + /// The time offset. + /// The hit result, or if doesn't result in a judgement. + protected virtual HitResult ResultFor(double timeOffset) => HitObject.HitWindows.ResultFor(timeOffset); + protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 13f5960bd4..79655c33e4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (JudgedObject?.HitObject is OsuHitObject osuObject) { - Position = osuObject.StackedPosition; + Position = osuObject.StackedEndPosition; Scale = new Vector2(osuObject.Scale); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 511cbc2347..9122f347d0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; using osuTK.Graphics; using osu.Game.Skinning; @@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public SliderBall Ball { get; private set; } public SkinnableDrawable Body { get; private set; } - public override bool DisplayResult => false; + public override bool DisplayResult => !HitObject.OnlyJudgeNestedObjects; private PlaySliderBody sliderBody => Body.Drawable as PlaySliderBody; @@ -249,7 +250,30 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (userTriggered || Time.Current < HitObject.EndTime) return; - ApplyResult(r => r.Type = NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult); + // If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes. + // But the slider needs to still be judged with a reasonable hit/miss result for visual purposes (hit/miss transforms, etc). + if (HitObject.OnlyJudgeNestedObjects) + { + ApplyResult(r => r.Type = NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult); + return; + } + + // Otherwise, if this slider also needs to be judged, apply judgement proportionally to the number of nested hitobjects hit. This is the classic osu!stable scoring. + ApplyResult(r => + { + int totalTicks = NestedHitObjects.Count; + int hitTicks = NestedHitObjects.Count(h => h.IsHit); + + if (hitTicks == totalTicks) + r.Type = HitResult.Great; + else if (hitTicks == 0) + r.Type = HitResult.Miss; + else + { + double hitFraction = (double)hitTicks / totalTicks; + r.Type = hitFraction >= 0.5 ? HitResult.Ok : HitResult.Meh; + } + }); } public override void PlaySamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index acc95ab036..01c0d988ee 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -7,16 +7,27 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSliderHead : DrawableHitCircle { + public new SliderHeadCircle HitObject => (SliderHeadCircle)base.HitObject; + [CanBeNull] public Slider Slider => DrawableSlider?.HitObject; protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + public override bool DisplayResult => HitObject?.JudgeAsNormalHitCircle ?? base.DisplayResult; + + /// + /// Makes this track the follow circle when the start time is reached. + /// If false, this will be pinned to its initial position in the slider. + /// + public bool TrackFollowCircle = true; + private readonly IBindable pathVersion = new Bindable(); protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle; @@ -59,12 +70,28 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.Update(); Debug.Assert(Slider != null); + Debug.Assert(HitObject != null); - double completionProgress = Math.Clamp((Time.Current - Slider.StartTime) / Slider.Duration, 0, 1); + if (TrackFollowCircle) + { + double completionProgress = Math.Clamp((Time.Current - Slider.StartTime) / Slider.Duration, 0, 1); - //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. - if (!IsHit) - Position = Slider.CurvePositionAt(completionProgress); + //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. + if (!IsHit) + Position = Slider.CurvePositionAt(completionProgress); + } + } + + protected override HitResult ResultFor(double timeOffset) + { + Debug.Assert(HitObject != null); + + if (HitObject.JudgeAsNormalHitCircle) + return base.ResultFor(timeOffset); + + // If not judged as a normal hitcircle, judge as a slider tick instead. This is the classic osu!stable scoring. + var result = base.ResultFor(timeOffset); + return result.IsHit() ? HitResult.LargeTickHit : HitResult.LargeTickMiss; } public Action OnShake; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 56aedebed3..3d614c2dbd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -130,7 +130,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { if (tracking.NewValue) { - spinningSample?.Play(!spinningSample.IsPlaying); + if (!spinningSample.IsPlaying) + spinningSample?.Play(); spinningSample?.VolumeTo(1, 300); } else @@ -157,6 +158,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } + protected override void UpdateStartTimeStateTransforms() + { + base.UpdateStartTimeStateTransforms(); + + if (Result?.TimeStarted is double startTime) + { + using (BeginAbsoluteSequence(startTime)) + fadeInCounter(); + } + } + protected override void UpdateHitStateTransforms(ArmedState state) { base.UpdateHitStateTransforms(state); @@ -243,7 +255,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.Update(); if (HandleUserInput) - RotationTracker.Tracking = !Result.HasResult && (OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false); + { + bool isValidSpinningTime = Time.Current >= HitObject.StartTime && Time.Current <= HitObject.EndTime; + bool correctButtonPressed = (OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false); + + RotationTracker.Tracking = !Result.HasResult + && correctButtonPressed + && isValidSpinningTime; + } if (spinningSample != null && spinnerFrequencyModulate) spinningSample.Frequency.Value = spinning_sample_modulated_base_frequency + Progress; @@ -254,12 +273,21 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.UpdateAfterChildren(); if (!SpmCounter.IsPresent && RotationTracker.Tracking) - SpmCounter.FadeIn(HitObject.TimeFadeIn); - SpmCounter.SetRotation(Result.RateAdjustedRotation); + { + Result.TimeStarted ??= Time.Current; + fadeInCounter(); + } + + // don't update after end time to avoid the rate display dropping during fade out. + // this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period. + if (Time.Current <= HitObject.EndTime) + SpmCounter.SetRotation(Result.RateAdjustedRotation); updateBonusScore(); } + private void fadeInCounter() => SpmCounter.FadeIn(HitObject.TimeFadeIn); + private int wholeSpins; private void updateBonusScore() diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 15af141c99..22b64af3df 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.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.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -25,6 +26,11 @@ namespace osu.Game.Rulesets.Osu.Objects /// internal const float BASE_SCORING_DISTANCE = 100; + /// + /// Minimum preempt time at AR=10. + /// + public const double PREEMPT_MIN = 450; + public double TimePreempt = 600; public double TimeFadeIn = 400; @@ -112,8 +118,13 @@ namespace osu.Game.Rulesets.Osu.Objects { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); - TimeFadeIn = 400; // as per osu-stable + TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, PREEMPT_MIN); + + // Preempt time can go below 450ms. Normally, this is achieved via the DT mod which uniformly speeds up all animations game wide regardless of AR. + // This uniform speedup is hard to match 1:1, however we can at least make AR>10 (via mods) feel good by extending the upper linear function above. + // Note that this doesn't exactly match the AR>10 visuals as they're classically known, but it feels good. + // This adjustment is necessary for AR>10, otherwise TimePreempt can become smaller leading to hitcircles not fully fading in. + TimeFadeIn = 400 * Math.Min(1, TimePreempt / PREEMPT_MIN); Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; } diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 1670df24a8..e2b6c84896 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -114,8 +114,14 @@ namespace osu.Game.Rulesets.Osu.Objects /// public double TickDistanceMultiplier = 1; + /// + /// Whether this 's judgement is fully handled by its nested s. + /// If false, this will be judged proportionally to the number of nested s hit. + /// + public bool OnlyJudgeNestedObjects = true; + [JsonIgnore] - public HitCircle HeadCircle { get; protected set; } + public SliderHeadCircle HeadCircle { get; protected set; } [JsonIgnore] public SliderTailCircle TailCircle { get; protected set; } @@ -140,7 +146,8 @@ namespace osu.Game.Rulesets.Osu.Objects // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. // For now, the samples are attached to and played by the slider itself at the correct end time. - Samples = this.GetNodeSamples(repeatCount + 1); + // ToArray call is required as GetNodeSamples may fallback to Samples itself (without it it will get cleared due to the list reference being live). + Samples = this.GetNodeSamples(repeatCount + 1).ToArray(); } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) @@ -233,7 +240,7 @@ namespace osu.Game.Rulesets.Osu.Objects HeadCircle.Samples = this.GetNodeSamples(0); } - public override Judgement CreateJudgement() => new OsuIgnoreJudgement(); + public override Judgement CreateJudgement() => OnlyJudgeNestedObjects ? new OsuIgnoreJudgement() : new OsuJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs index f6d46aeef5..5672283230 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs @@ -1,9 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Judgements; + namespace osu.Game.Rulesets.Osu.Objects { public class SliderHeadCircle : HitCircle { + /// + /// Whether to treat this as a normal for judgement purposes. + /// If false, this will be judged as a instead. + /// + public bool JudgeAsNormalHitCircle = true; + + public override Judgement CreateJudgement() => JudgeAsNormalHitCircle ? base.CreateJudgement() : new SliderTickJudgement(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index a427ee1955..725dbe81fb 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -33,10 +33,5 @@ namespace osu.Game.Rulesets.Osu.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; public override Judgement CreateJudgement() => new SliderTickJudgement(); - - public class SliderTickJudgement : OsuJudgement - { - public override HitResult MaxResult => HitResult.LargeTickHit; - } } } diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index c8fe4f41ca..7314021a14 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu protected override bool Handle(UIEvent e) { - if (e is MouseMoveEvent && !AllowUserCursorMovement) return false; + if ((e is MouseMoveEvent || e is TouchMoveEvent) && !AllowUserCursorMovement) return false; return base.Handle(e); } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index cba0c5be14..838d707d64 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -29,6 +29,7 @@ using osu.Game.Scoring; using osu.Game.Skinning; using System; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.Statistics; @@ -58,52 +59,52 @@ namespace osu.Game.Rulesets.Osu public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new OsuModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new OsuModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new OsuModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new OsuModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Autopilot)) + if (mods.HasFlagFast(LegacyMods.Autopilot)) yield return new OsuModAutopilot(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new OsuModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new OsuModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new OsuModEasy(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new OsuModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new OsuModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new OsuModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new OsuModHidden(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new OsuModNoFail(); - if (mods.HasFlag(LegacyMods.Relax)) + if (mods.HasFlagFast(LegacyMods.Relax)) yield return new OsuModRelax(); - if (mods.HasFlag(LegacyMods.SpunOut)) + if (mods.HasFlagFast(LegacyMods.SpunOut)) yield return new OsuModSpunOut(); - if (mods.HasFlag(LegacyMods.Target)) + if (mods.HasFlagFast(LegacyMods.Target)) yield return new OsuModTarget(); - if (mods.HasFlag(LegacyMods.TouchDevice)) + if (mods.HasFlagFast(LegacyMods.TouchDevice)) yield return new OsuModTouchDevice(); } @@ -163,6 +164,7 @@ namespace osu.Game.Rulesets.Osu { new OsuModTarget(), new OsuModDifficultyAdjust(), + new OsuModClassic() }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 954a217473..693943a08a 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -6,10 +6,12 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Game.Replays; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Scoring; @@ -33,11 +35,6 @@ namespace osu.Game.Rulesets.Osu.Replays #region Constants - /// - /// The "reaction time" in ms between "seeing" a new hit object and moving to "react" to it. - /// - private readonly double reactionTime; - private readonly HitWindows defaultHitWindows; /// @@ -49,12 +46,9 @@ namespace osu.Game.Rulesets.Osu.Replays #region Construction / Initialisation - public OsuAutoGenerator(IBeatmap beatmap) - : base(beatmap) + public OsuAutoGenerator(IBeatmap beatmap, IReadOnlyList mods) + : base(beatmap, mods) { - // Already superhuman, but still somewhat realistic - reactionTime = ApplyModsToRate(100); - defaultHitWindows = new OsuHitWindows(); defaultHitWindows.SetDifficulty(Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); } @@ -240,7 +234,7 @@ namespace osu.Game.Rulesets.Osu.Replays OsuReplayFrame lastFrame = (OsuReplayFrame)Frames[^1]; // Wait until Auto could "see and react" to the next note. - double waitTime = h.StartTime - Math.Max(0.0, h.TimePreempt - reactionTime); + double waitTime = h.StartTime - Math.Max(0.0, h.TimePreempt - getReactionTime(h.StartTime - h.TimePreempt)); if (waitTime > lastFrame.Time) { @@ -250,7 +244,7 @@ namespace osu.Game.Rulesets.Osu.Replays Vector2 lastPosition = lastFrame.Position; - double timeDifference = ApplyModsToTime(h.StartTime - lastFrame.Time); + double timeDifference = ApplyModsToTimeDelta(lastFrame.Time, h.StartTime); // Only "snap" to hitcircles if they are far enough apart. As the time between hitcircles gets shorter the snapping threshold goes up. if (timeDifference > 0 && // Sanity checks @@ -258,7 +252,7 @@ namespace osu.Game.Rulesets.Osu.Replays timeDifference >= 266)) // ... or the beats are slow enough to tap anyway. { // Perform eased movement - for (double time = lastFrame.Time + FrameDelay; time < h.StartTime; time += FrameDelay) + for (double time = lastFrame.Time + GetFrameDelay(lastFrame.Time); time < h.StartTime; time += GetFrameDelay(time)) { Vector2 currentPosition = Interpolation.ValueAt(time, lastPosition, targetPos, lastFrame.Time, h.StartTime, easing); AddFrameToReplay(new OsuReplayFrame((int)time, new Vector2(currentPosition.X, currentPosition.Y)) { Actions = lastFrame.Actions }); @@ -272,6 +266,14 @@ namespace osu.Game.Rulesets.Osu.Replays } } + /// + /// Calculates the "reaction time" in ms between "seeing" a new hit object and moving to "react" to it. + /// + /// + /// Already superhuman, but still somewhat realistic. + /// + private double getReactionTime(double timeInstant) => ApplyModsToRate(timeInstant, 100); + // Add frames to click the hitobject private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, float spinnerDirection) { @@ -341,17 +343,23 @@ namespace osu.Game.Rulesets.Osu.Replays float angle = radius == 0 ? 0 : MathF.Atan2(difference.Y, difference.X); double t; + double previousFrame = h.StartTime; - for (double j = h.StartTime + FrameDelay; j < spinner.EndTime; j += FrameDelay) + for (double nextFrame = h.StartTime + GetFrameDelay(h.StartTime); nextFrame < spinner.EndTime; nextFrame += GetFrameDelay(nextFrame)) { - t = ApplyModsToTime(j - h.StartTime) * spinnerDirection; + t = ApplyModsToTimeDelta(previousFrame, nextFrame) * spinnerDirection; + angle += (float)t / 20; - Vector2 pos = SPINNER_CENTRE + CirclePosition(t / 20 + angle, SPIN_RADIUS); - AddFrameToReplay(new OsuReplayFrame((int)j, new Vector2(pos.X, pos.Y), action)); + Vector2 pos = SPINNER_CENTRE + CirclePosition(angle, SPIN_RADIUS); + AddFrameToReplay(new OsuReplayFrame((int)nextFrame, new Vector2(pos.X, pos.Y), action)); + + previousFrame = nextFrame; } - t = ApplyModsToTime(spinner.EndTime - h.StartTime) * spinnerDirection; - Vector2 endPosition = SPINNER_CENTRE + CirclePosition(t / 20 + angle, SPIN_RADIUS); + t = ApplyModsToTimeDelta(previousFrame, spinner.EndTime) * spinnerDirection; + angle += (float)t / 20; + + Vector2 endPosition = SPINNER_CENTRE + CirclePosition(angle, SPIN_RADIUS); AddFrameToReplay(new OsuReplayFrame(spinner.EndTime, new Vector2(endPosition.X, endPosition.Y), action)); @@ -359,7 +367,7 @@ namespace osu.Game.Rulesets.Osu.Replays break; case Slider slider: - for (double j = FrameDelay; j < slider.Duration; j += FrameDelay) + for (double j = GetFrameDelay(slider.StartTime); j < slider.Duration; j += GetFrameDelay(slider.StartTime + j)) { Vector2 pos = slider.StackedPositionAt(j / slider.Duration); AddFrameToReplay(new OsuReplayFrame(h.StartTime + j, new Vector2(pos.X, pos.Y), action)); diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs index 3356a0fbe0..1cb3208c30 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs @@ -5,7 +5,9 @@ using osuTK; using osu.Game.Beatmaps; using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Replays; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; @@ -22,33 +24,61 @@ namespace osu.Game.Rulesets.Osu.Replays public const float SPIN_RADIUS = 50; - /// - /// The time in ms between each ReplayFrame. - /// - protected readonly double FrameDelay; - #endregion #region Construction / Initialisation protected Replay Replay; protected List Frames => Replay.Frames; + private readonly IReadOnlyList timeAffectingMods; - protected OsuAutoGeneratorBase(IBeatmap beatmap) + protected OsuAutoGeneratorBase(IBeatmap beatmap, IReadOnlyList mods) : base(beatmap) { Replay = new Replay(); - // We are using ApplyModsToRate and not ApplyModsToTime to counteract the speed up / slow down from HalfTime / DoubleTime so that we remain at a constant framerate of 60 fps. - FrameDelay = ApplyModsToRate(1000.0 / 60.0); + timeAffectingMods = mods.OfType().ToList(); } #endregion #region Utilities - protected double ApplyModsToTime(double v) => v; - protected double ApplyModsToRate(double v) => v; + /// + /// Returns the real duration of time between and + /// after applying rate-affecting mods. + /// + /// + /// This method should only be used when and are very close. + /// That is because the track rate might be changing with time, + /// and the method used here is a rough instantaneous approximation. + /// + /// The start time of the time delta, in original track time. + /// The end time of the time delta, in original track time. + protected double ApplyModsToTimeDelta(double startTime, double endTime) + { + double delta = endTime - startTime; + + foreach (var mod in timeAffectingMods) + delta /= mod.ApplyToRate(startTime); + + return delta; + } + + protected double ApplyModsToRate(double time, double rate) + { + foreach (var mod in timeAffectingMods) + rate = mod.ApplyToRate(time, rate); + return rate; + } + + /// + /// Calculates the interval after which the next should be generated, + /// in milliseconds. + /// + /// The time of the previous frame. + protected double GetFrameDelay(double time) + => ApplyModsToRate(time, 1000.0 / 60); private class ReplayFrameComparer : IComparer { diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs index bea6186501..43d8d1e27f 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public string Text { - get => number.Text; + get => number.Text.ToString(); set => number.Text = value; } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs index e77c93c721..4dd7b2d69c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default [Resolved(CanBeNull = true)] private OsuRulesetConfigManager config { get; set; } + private readonly Bindable configSnakingOut = new Bindable(); + [BackgroundDependencyLoader] private void load(ISkinSource skin, DrawableHitObject drawableObject) { @@ -36,10 +38,28 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default accentColour.BindValueChanged(accent => updateAccentColour(skin, accent.NewValue), true); config?.BindWith(OsuRulesetSetting.SnakingInSliders, SnakingIn); - config?.BindWith(OsuRulesetSetting.SnakingOutSliders, SnakingOut); + config?.BindWith(OsuRulesetSetting.SnakingOutSliders, configSnakingOut); + + SnakingOut.BindTo(configSnakingOut); BorderSize = skin.GetConfig(OsuSkinConfiguration.SliderBorderSize)?.Value ?? 1; BorderColour = skin.GetConfig(OsuSkinColour.SliderBorder)?.Value ?? Color4.White; + + drawableObject.HitObjectApplied += onHitObjectApplied; + } + + private void onHitObjectApplied(DrawableHitObject obj) + { + var drawableSlider = (DrawableSlider)obj; + if (drawableSlider.HitObject == null) + return; + + // When not tracking the follow circle, unbind from the config and forcefully disable snaking out - it looks better that way. + if (!drawableSlider.HeadCircle.TrackFollowCircle) + { + SnakingOut.UnbindFrom(configSnakingOut); + SnakingOut.Value = false; + } } private void updateAccentColour(ISkinSource skin, Color4 defaultAccentColour) diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs index a96beb66d4..82b677e12c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs @@ -31,6 +31,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default set => ball.Colour = value; } + /// + /// Whether to track accurately to the visual size of this . + /// If false, tracking will be performed at the final scale at all times. + /// + public bool InputTracksVisualSize = true; + private readonly Drawable followCircle; private readonly DrawableSlider drawableSlider; private readonly Drawable ball; @@ -94,7 +100,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default tracking = value; - followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); + if (InputTracksVisualSize) + followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); + else + { + // We need to always be tracking the final size, at both endpoints. For now, this is achieved by removing the scale duration. + followCircle.ScaleTo(tracking ? 2.4f : 1f); + } + followCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs index e5952ecf97..69355f624b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs @@ -4,16 +4,21 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Skinning.Default { public class SpinnerSpmCounter : Container { + [Resolved] + private DrawableHitObject drawableSpinner { get; set; } + private readonly OsuSpriteText spmText; public SpinnerSpmCounter() @@ -38,6 +43,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default }; } + protected override void LoadComplete() + { + base.LoadComplete(); + drawableSpinner.HitObjectApplied += resetState; + } + private double spm; public double SpinsPerMinute @@ -82,5 +93,19 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default records.Enqueue(new RotationRecord { Rotation = currentRotation, Time = Time.Current }); } + + private void resetState(DrawableHitObject hitObject) + { + SpinsPerMinute = 0; + records.Clear(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner != null) + drawableSpinner.HitObjectApplied -= resetState; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index efeca53969..22fb3aab86 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -37,9 +37,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy AddInternal(scaleContainer = new Container { Scale = new Vector2(SPRITE_SCALE), - Anchor = Anchor.Centre, + Anchor = Anchor.TopCentre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, + Y = SPINNER_Y_CENTRE, Children = new Drawable[] { glow = new Sprite diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs index 4e07cb60b3..19cb55c16e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs @@ -33,47 +33,38 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { spinnerBlink = source.GetConfig(OsuSkinConfiguration.SpinnerNoBlink)?.Value != true; - AddInternal(new Container + AddRangeInternal(new Drawable[] { - // the old-style spinner relied heavily on absolute screen-space coordinate values. - // wrap everything in a container simulating absolute coords to preserve alignment - // as there are skins that depend on it. - Width = 640, - Height = 480, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] + new Sprite { - new Sprite + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-background"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_Y_CENTRE, + }, + disc = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-circle"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_Y_CENTRE, + }, + metre = new Container + { + AutoSizeAxes = Axes.Both, + // this anchor makes no sense, but that's what stable uses. + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Margin = new MarginPadding { Top = SPINNER_TOP_OFFSET }, + Masking = true, + Child = metreSprite = new Sprite { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-background"), - Scale = new Vector2(SPRITE_SCALE) - }, - disc = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-circle"), - Scale = new Vector2(SPRITE_SCALE) - }, - metre = new Container - { - AutoSizeAxes = Axes.Both, - // this anchor makes no sense, but that's what stable uses. + Texture = source.GetTexture("spinner-metre"), Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, - // adjustment for stable (metre has additional offset) - Margin = new MarginPadding { Top = 20 }, - Masking = true, - Child = metreSprite = new Sprite - { - Texture = source.GetTexture("spinner-metre"), - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Scale = new Vector2(SPRITE_SCALE) - } + Scale = new Vector2(SPRITE_SCALE) } } }); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index ec7ecb0d28..513888db53 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -16,6 +16,15 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public abstract class LegacySpinner : CompositeDrawable { + /// + /// All constants are in osu!stable's gamefield space, which is shifted 16px downwards. + /// This offset is negated in both osu!stable and osu!lazer to bring all constants into window-space. + /// Note: SPINNER_Y_CENTRE + SPINNER_TOP_OFFSET - Position.Y = 240 (=480/2, or half the window-space in osu!stable) + /// + protected const float SPINNER_TOP_OFFSET = 45f - 16f; + + protected const float SPINNER_Y_CENTRE = SPINNER_TOP_OFFSET + 219f; + protected const float SPRITE_SCALE = 0.625f; protected DrawableSpinner DrawableSpinner { get; private set; } @@ -26,7 +35,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy [BackgroundDependencyLoader] private void load(DrawableHitObject drawableHitObject, ISkinSource source) { - RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + // osu!stable positions spinner components in window-space (as opposed to gamefield-space). This is a 640x480 area taking up the entire screen. + // In lazer, the gamefield-space positional transformation is applied in OsuPlayfieldAdjustmentContainer, which is inverted here to make this area take up the entire window space. + Size = new Vector2(640, 480); + Position = new Vector2(0, -8f); DrawableSpinner = (DrawableSpinner)drawableHitObject; @@ -34,22 +49,22 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { spin = new Sprite { - Anchor = Anchor.Centre, + Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Depth = float.MinValue, Texture = source.GetTexture("spinner-spin"), Scale = new Vector2(SPRITE_SCALE), - Y = 120 - 45 // offset temporarily to avoid overlapping default spin counter + Y = SPINNER_TOP_OFFSET + 335, }, clear = new Sprite { - Anchor = Anchor.Centre, + Alpha = 0, + Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Depth = float.MinValue, - Alpha = 0, Texture = source.GetTexture("spinner-clear"), Scale = new Vector2(SPRITE_SCALE), - Y = -60 + Y = SPINNER_TOP_OFFSET + 115, }, }); } diff --git a/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs new file mode 100644 index 0000000000..5d8ea035a7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/IHitPolicy.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.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.UI +{ + public interface IHitPolicy + { + /// + /// The containing the s which this applies to. + /// + IHitObjectContainer HitObjectContainer { set; } + + /// + /// Determines whether a can be hit at a point in time. + /// + /// The to check. + /// The time to check. + /// Whether can be hit at the given . + bool IsHittable(DrawableHitObject hitObject, double time); + + /// + /// Handles a being hit. + /// + /// The that was hit. + void HandleHit(DrawableHitObject hitObject); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs new file mode 100644 index 0000000000..83f205deac --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Ensures that s are hit in order of appearance. The classic note lock. + /// + /// Hits will be blocked until the previous s have been judged. + /// + /// + public class ObjectOrderedHitPolicy : IHitPolicy + { + public IHitObjectContainer HitObjectContainer { get; set; } + + public bool IsHittable(DrawableHitObject hitObject, double time) => enumerateHitObjectsUpTo(hitObject.HitObject.StartTime).All(obj => obj.AllJudged); + + public void HandleHit(DrawableHitObject hitObject) + { + } + + private IEnumerable enumerateHitObjectsUpTo(double targetTime) + { + foreach (var obj in HitObjectContainer.AliveObjects) + { + if (obj.HitObject.StartTime >= targetTime) + yield break; + + switch (obj) + { + case DrawableSpinner _: + continue; + + case DrawableSlider slider: + yield return slider.HeadCircle; + + break; + + default: + yield return obj; + + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 975b444699..b1069149f3 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -31,7 +31,6 @@ namespace osu.Game.Rulesets.Osu.UI private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; private readonly FollowPointRenderer followPoints; - private readonly OrderedHitPolicy hitPolicy; public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); @@ -54,10 +53,9 @@ namespace osu.Game.Rulesets.Osu.UI approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both }, }; - hitPolicy = new OrderedHitPolicy(HitObjectContainer); + HitPolicy = new StartTimeOrderedHitPolicy(); var hitWindows = new OsuHitWindows(); - foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgmentLoaded)); @@ -66,6 +64,18 @@ namespace osu.Game.Rulesets.Osu.UI NewResult += onNewResult; } + private IHitPolicy hitPolicy; + + public IHitPolicy HitPolicy + { + get => hitPolicy; + set + { + hitPolicy = value ?? throw new ArgumentNullException(nameof(value)); + hitPolicy.HitObjectContainer = HitObjectContainer; + } + } + protected override void OnNewDrawableHitObject(DrawableHitObject drawable) { ((DrawableOsuHitObject)drawable).CheckHittable = hitPolicy.IsHittable; diff --git a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs similarity index 76% rename from osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs rename to osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs index 8e4f81347d..0173156246 100644 --- a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs @@ -11,28 +11,17 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.UI { /// - /// Ensures that s are hit in-order. Affectionately known as "note lock". + /// Ensures that s are hit in-order of their start times. Affectionately known as "note lock". /// If a is hit out of order: /// /// The hit is blocked if it occurred earlier than the previous 's start time. /// The hit causes all previous s to missed otherwise. /// /// - public class OrderedHitPolicy + public class StartTimeOrderedHitPolicy : IHitPolicy { - private readonly HitObjectContainer hitObjectContainer; + public IHitObjectContainer HitObjectContainer { get; set; } - public OrderedHitPolicy(HitObjectContainer hitObjectContainer) - { - this.hitObjectContainer = hitObjectContainer; - } - - /// - /// Determines whether a can be hit at a point in time. - /// - /// The to check. - /// The time to check. - /// Whether can be hit at the given . public bool IsHittable(DrawableHitObject hitObject, double time) { DrawableHitObject blockingObject = null; @@ -54,10 +43,6 @@ namespace osu.Game.Rulesets.Osu.UI return blockingObject.Judged || time >= blockingObject.HitObject.StartTime; } - /// - /// Handles a being hit to potentially miss all earlier s. - /// - /// The that was hit. public void HandleHit(DrawableHitObject hitObject) { // Hitobjects which themselves don't block future hitobjects don't cause misses (e.g. slider ticks, spinners). @@ -67,6 +52,7 @@ namespace osu.Game.Rulesets.Osu.UI if (!IsHittable(hitObject, hitObject.HitObject.StartTime + hitObject.Result.TimeOffset)) throw new InvalidOperationException($"A {hitObject} was hit before it became hittable!"); + // Miss all hitobjects prior to the hit one. foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) { if (obj.Judged) @@ -86,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.UI private IEnumerable enumerateHitObjectsUpTo(double targetTime) { - foreach (var obj in hitObjectContainer.AliveObjects) + foreach (var obj in HitObjectContainer.AliveObjects) { if (obj.HitObject.StartTime >= targetTime) yield break; diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs index fecb5d4a74..61ea8b664d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs @@ -2,8 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -13,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestFixture] public class TestSceneHitExplosion : TaikoSkinnableTestScene { + protected override double TimePerAction => 100; + [Test] public void TestNormalHit() { @@ -21,11 +27,14 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning AddStep("Miss", () => SetContents(() => getContentFor(createHit(HitResult.Miss)))); } - [Test] - public void TestStrongHit([Values(false, true)] bool hitBoth) + [TestCase(HitResult.Great)] + [TestCase(HitResult.Ok)] + public void TestStrongHit(HitResult type) { - AddStep("Great", () => SetContents(() => getContentFor(createStrongHit(HitResult.Great, hitBoth)))); - AddStep("Good", () => SetContents(() => getContentFor(createStrongHit(HitResult.Ok, hitBoth)))); + AddStep("create hit", () => SetContents(() => getContentFor(createStrongHit(type)))); + AddStep("visualise second hit", + () => this.ChildrenOfType() + .ForEach(e => e.VisualiseSecondHit(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement())))); } private Drawable getContentFor(DrawableTestHit hit) @@ -38,17 +47,17 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning // the hit needs to be added to hierarchy in order for nested objects to be created correctly. // setting zero alpha is supposed to prevent the test from looking broken. hit.With(h => h.Alpha = 0), - new HitExplosion(hit, hit.Type) + new HitExplosion(hit.Type) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - } + }.With(explosion => explosion.Apply(hit)) } }; } private DrawableTestHit createHit(HitResult type) => new DrawableTestHit(new Hit { StartTime = Time.Current }, type); - private DrawableTestHit createStrongHit(HitResult type, bool hitBoth) => new DrawableTestStrongHit(Time.Current, type, hitBoth); + private DrawableTestHit createStrongHit(HitResult type) => new DrawableTestStrongHit(Time.Current, type); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index 71b3c23b50..eb21c02d5f 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Taiko.Difficulty; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Taiko.Tests @@ -18,6 +19,11 @@ namespace osu.Game.Rulesets.Taiko.Tests public void Test(double expected, string name) => base.Test(expected, name); + [TestCase(3.1473940254109078d, "diffcalc-test")] + [TestCase(3.1473940254109078d, "diffcalc-test-strong")] + public void TestClockRateAdjusted(double expected, string name) + => Test(expected, name, new TaikoModDoubleTime()); + protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset(), beatmap); protected override Ruleset CreateRuleset() => new TaikoRuleset(); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index 7695ca067b..87c936d386 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -11,7 +11,6 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; @@ -108,12 +107,12 @@ namespace osu.Game.Rulesets.Taiko.Tests { HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Ok : HitResult.Great; - Hit hit = new Hit(); + Hit hit = new Hit { StartTime = DrawableRuleset.Playfield.Time.Current }; var h = new DrawableTestHit(hit, kiai: kiai) { X = RNG.NextSingle(hitResult == HitResult.Ok ? -0.1f : -0.05f, hitResult == HitResult.Ok ? 0.1f : 0.05f) }; DrawableRuleset.Playfield.Add(h); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(hit, new TaikoJudgement()) { Type = hitResult }); } private void addStrongHitJudgement(bool kiai) @@ -122,6 +121,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Hit hit = new Hit { + StartTime = DrawableRuleset.Playfield.Time.Current, IsStrong = true, Samples = createSamples(strong: true) }; @@ -129,8 +129,8 @@ namespace osu.Game.Rulesets.Taiko.Tests DrawableRuleset.Playfield.Add(h); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h.NestedHitObjects.Single(), new JudgementResult(new HitObject(), new TaikoStrongJudgement()) { Type = HitResult.Great }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(hit, new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h.NestedHitObjects.Single(), new JudgementResult(hit.NestedHitObjects.Single(), new TaikoStrongJudgement()) { Type = HitResult.Great }); } private void addMissJudgement() diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index 2a5a2e2fdb..c9a320bdd5 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -2,10 +2,10 @@ - - + + - + WinExe diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs index 32421ee00a..cc0738e252 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs @@ -5,6 +5,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Objects; @@ -39,6 +40,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// private int currentMonoLength; + public Colour(Mod[] mods) + : base(mods) + { + } + protected override double StrainValueOf(DifficultyHitObject current) { // changing from/to a drum roll or a swell does not constitute a colour change. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index 5569b27ad5..f2b8309ac5 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -5,6 +5,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Objects; @@ -47,6 +48,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// private int notesSinceRhythmChange; + public Rhythm(Mod[] mods) + : base(mods) + { + } + protected override double StrainValueOf(DifficultyHitObject current) { // drum rolls and swells are exempt. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 0b61eb9930..c34cce0cd6 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -5,6 +5,7 @@ using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Objects; @@ -48,8 +49,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// Creates a skill. /// + /// Mods for use in skill calculations. /// Whether this instance is performing calculations for the right hand. - public Stamina(bool rightHand) + public Stamina(Mod[] mods, bool rightHand) + : base(mods) { hand = rightHand ? 1 : 0; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index e5485db4df..fc198d2493 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -29,12 +29,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { } - protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { - new Colour(), - new Rhythm(), - new Stamina(true), - new Stamina(false), + new Colour(mods), + new Rhythm(mods), + new Stamina(mods, true), + new Stamina(mods, false), }; protected override Mod[] DifficultyAdjustmentMods => new Mod[] diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index 3fbcee44af..ac2dd4bdb6 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -52,32 +52,24 @@ namespace osu.Game.Rulesets.Taiko.Edit public void SetStrongState(bool state) { - var hits = EditorBeatmap.SelectedHitObjects.OfType(); - - EditorBeatmap.BeginChange(); - - foreach (var h in hits) + EditorBeatmap.PerformOnSelection(h => { - if (h.IsStrong != state) - { - h.IsStrong = state; - EditorBeatmap.Update(h); - } - } + if (!(h is Hit taikoHit)) return; - EditorBeatmap.EndChange(); + if (taikoHit.IsStrong != state) + { + taikoHit.IsStrong = state; + EditorBeatmap.Update(taikoHit); + } + }); } public void SetRimState(bool state) { - var hits = EditorBeatmap.SelectedHitObjects.OfType(); - - EditorBeatmap.BeginChange(); - - foreach (var h in hits) - h.Type = state ? HitType.Rim : HitType.Centre; - - EditorBeatmap.EndChange(); + EditorBeatmap.PerformOnSelection(h => + { + if (h is Hit taikoHit) taikoHit.Type = state ? HitType.Rim : HitType.Centre; + }); } protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 5b890b3d03..64e59b64d0 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.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.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModAutoplay : ModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "mekkadosu!" } }, Replay = new TaikoAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs index 71aa007d3b..00f0c8e321 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.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.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModCinema : ModCinema { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "mekkadosu!" } }, Replay = new TaikoAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 56a73ad7df..4006652bd5 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -1,11 +1,45 @@ // 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.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModDifficultyAdjust : ModDifficultyAdjust { + [SettingSource("Scroll Speed", "Adjust a beatmap's set scroll speed", LAST_SETTING_ORDER + 1)] + public BindableNumber ScrollSpeed { get; } = new BindableFloat + { + Precision = 0.05f, + MinValue = 0.25f, + MaxValue = 4, + Default = 1, + Value = 1, + }; + + public override string SettingDescription + { + get + { + string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N1}"; + + return string.Join(", ", new[] + { + base.SettingDescription, + scrollSpeed + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + + protected override void ApplySettings(BeatmapDifficulty difficulty) + { + base.ApplySettings(difficulty); + + ApplySetting(ScrollSpeed, scroll => difficulty.SliderMultiplier *= scroll); + } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs index d1ad4c9d8d..ad6fdf59e2 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods @@ -8,5 +9,16 @@ namespace osu.Game.Rulesets.Taiko.Mods public class TaikoModEasy : ModEasy { public override string Description => @"Beats move slower, and less accuracy required!"; + + /// + /// Multiplier factor added to the scrolling speed. + /// + private const double slider_multiplier = 0.8; + + public override void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + base.ApplyToDifficulty(difficulty); + difficulty.SliderMultiplier *= slider_multiplier; + } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs index 49d225cdb5..a5a8b75f80 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods @@ -9,5 +10,21 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override double ScoreMultiplier => 1.06; public override bool Ranked => true; + + /// + /// Multiplier factor added to the scrolling speed. + /// + /// + /// This factor is made up of two parts: the base part (1.4) and the aspect ratio adjustment (4/3). + /// Stable applies the latter by dividing the width of the user's display by the width of a display with the same height, but 4:3 aspect ratio. + /// TODO: Revisit if taiko playfield ever changes away from a hard-coded 16:9 (see https://github.com/ppy/osu/issues/5685). + /// + private const double slider_multiplier = 1.4 * 4 / 3; + + public override void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + base.ApplyToDifficulty(difficulty); + difficulty.SliderMultiplier *= slider_multiplier; + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs index 651cdd6438..21bd35ad22 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs @@ -1,22 +1,22 @@ // 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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyHitExplosion : CompositeDrawable + public class LegacyHitExplosion : CompositeDrawable, IAnimatableHitExplosion { private readonly Drawable sprite; - private readonly Drawable strongSprite; - private DrawableStrongNestedHit nestedStrongHit; - private bool switchedToStrongSprite; + [CanBeNull] + private readonly Drawable strongSprite; /// /// Creates a new legacy hit explosion. @@ -27,14 +27,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// /// The normal legacy explosion sprite. /// The strong legacy explosion sprite. - public LegacyHitExplosion(Drawable sprite, Drawable strongSprite = null) + public LegacyHitExplosion(Drawable sprite, [CanBeNull] Drawable strongSprite = null) { this.sprite = sprite; this.strongSprite = strongSprite; } [BackgroundDependencyLoader] - private void load(DrawableHitObject judgedObject) + private void load() { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -56,45 +56,30 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy s.Origin = Anchor.Centre; })); } - - if (judgedObject is DrawableHit hit) - nestedStrongHit = hit.NestedHitObjects.SingleOrDefault() as DrawableStrongNestedHit; } - protected override void LoadComplete() + public void Animate(DrawableHitObject drawableHitObject) { - base.LoadComplete(); - const double animation_time = 120; + (sprite as IFramedAnimation)?.GotoFrame(0); + (strongSprite as IFramedAnimation)?.GotoFrame(0); + this.FadeInFromZero(animation_time).Then().FadeOut(animation_time * 1.5); this.ScaleTo(0.6f) .Then().ScaleTo(1.1f, animation_time * 0.8) .Then().ScaleTo(0.9f, animation_time * 0.4) .Then().ScaleTo(1f, animation_time * 0.2); - - Expire(true); } - protected override void Update() + public void AnimateSecondHit() { - base.Update(); + if (strongSprite == null) + return; - if (shouldSwitchToStrongSprite() && !switchedToStrongSprite) - { - sprite.FadeOut(50, Easing.OutQuint); - strongSprite.FadeIn(50, Easing.OutQuint); - switchedToStrongSprite = true; - } - } - - private bool shouldSwitchToStrongSprite() - { - if (nestedStrongHit == null || strongSprite == null) - return false; - - return nestedStrongHit.IsHit; + sprite.FadeOut(50, Easing.OutQuint); + strongSprite.FadeIn(50, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index d8e3100048..d97da40ef2 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { // if a taiko skin is providing explosion sprites, hide the judgements completely if (hasExplosion.Value) - return Drawable.Empty(); + return Drawable.Empty().With(d => d.Expire()); } if (!(component is TaikoSkinComponent taikoComponent)) @@ -118,7 +118,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy // suppress the default kiai explosion if the skin brings its own sprites. // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. if (hasExplosion.Value) - return Drawable.Empty().With(d => d.LifetimeEnd = double.MinValue); + return Drawable.Empty().With(d => d.Expire()); return null; @@ -152,7 +152,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); } - public override SampleChannel GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); + public override Sample GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); public override IBindable GetConfig(TLookup lookup) => Source.GetConfig(lookup); diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index f2b5d195b4..56f58f404b 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; using System; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Edit; using osu.Game.Rulesets.Taiko.Objects; @@ -57,43 +58,43 @@ namespace osu.Game.Rulesets.Taiko public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new TaikoModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new TaikoModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new TaikoModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new TaikoModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new TaikoModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new TaikoModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new TaikoModEasy(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new TaikoModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new TaikoModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new TaikoModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new TaikoModHidden(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new TaikoModNoFail(); - if (mods.HasFlag(LegacyMods.Relax)) + if (mods.HasFlagFast(LegacyMods.Relax)) yield return new TaikoModRelax(); - if (mods.HasFlag(LegacyMods.Random)) + if (mods.HasFlagFast(LegacyMods.Random)) yield return new TaikoModRandom(); } diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs index 3bd20e4bb4..91e844187a 100644 --- a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,19 +14,23 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { - internal class DefaultHitExplosion : CircularContainer + internal class DefaultHitExplosion : CircularContainer, IAnimatableHitExplosion { - private readonly DrawableHitObject judgedObject; private readonly HitResult result; - public DefaultHitExplosion(DrawableHitObject judgedObject, HitResult result) + [CanBeNull] + private Box body; + + [Resolved] + private OsuColour colours { get; set; } + + public DefaultHitExplosion(HitResult result) { - this.judgedObject = judgedObject; this.result = result; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { RelativeSizeAxes = Axes.Both; @@ -40,26 +45,36 @@ namespace osu.Game.Rulesets.Taiko.UI if (!result.IsHit()) return; - bool isRim = (judgedObject.HitObject as Hit)?.Type == HitType.Rim; - InternalChildren = new[] { - new Box + body = new Box { RelativeSizeAxes = Axes.Both, - Colour = isRim ? colours.BlueDarker : colours.PinkDarker, } }; + + updateColour(); } - protected override void LoadComplete() + private void updateColour([CanBeNull] DrawableHitObject judgedObject = null) { - base.LoadComplete(); + if (body == null) + return; + + bool isRim = (judgedObject?.HitObject as Hit)?.Type == HitType.Rim; + body.Colour = isRim ? colours.BlueDarker : colours.PinkDarker; + } + + public void Animate(DrawableHitObject drawableHitObject) + { + updateColour(drawableHitObject); this.ScaleTo(3f, 1000, Easing.OutQuint); this.FadeOut(500); + } - Expire(true); + public void AnimateSecondHit() + { } } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index b5e35f88b5..1ad1e4495c 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -3,7 +3,6 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.UI { @@ -12,16 +11,6 @@ namespace osu.Game.Rulesets.Taiko.UI /// public class DrawableTaikoJudgement : DrawableJudgement { - /// - /// Creates a new judgement text. - /// - /// The object which is being judged. - /// The judgement to visualise. - public DrawableTaikoJudgement(JudgementResult result, DrawableHitObject judgedObject) - : base(result, judgedObject) - { - } - protected override void ApplyHitAnimations() { this.MoveToY(-100, 500); diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index d1fb3348b9..8f5e9e54ab 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; @@ -16,31 +18,37 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A circle explodes from the hit target to indicate a hitobject has been hit. /// - internal class HitExplosion : CircularContainer + internal class HitExplosion : PoolableDrawable { public override bool RemoveWhenNotAlive => true; - - [Cached(typeof(DrawableHitObject))] - public readonly DrawableHitObject JudgedObject; + public override bool RemoveCompletedTransforms => false; private readonly HitResult result; + private double? secondHitTime; + + [CanBeNull] + public DrawableHitObject JudgedObject; + private SkinnableDrawable skinnable; - public override double LifetimeStart => skinnable.Drawable.LifetimeStart; - - public override double LifetimeEnd => skinnable.Drawable.LifetimeEnd; - - public HitExplosion(DrawableHitObject judgedObject, HitResult result) + /// + /// This constructor only exists to meet the new() type constraint of . + /// + public HitExplosion() + : this(HitResult.Great) + { + } + + public HitExplosion(HitResult result) { - JudgedObject = judgedObject; this.result = result; Anchor = Anchor.Centre; Origin = Anchor.Centre; - RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE); + RelativeSizeAxes = Axes.Both; RelativePositionAxes = Axes.Both; } @@ -48,7 +56,47 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(JudgedObject, result)); + InternalChild = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(result)); + skinnable.OnSkinChanged += runAnimation; + } + + public void Apply([CanBeNull] DrawableHitObject drawableHitObject) + { + JudgedObject = drawableHitObject; + secondHitTime = null; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + runAnimation(); + } + + private void runAnimation() + { + if (JudgedObject?.Result == null) + return; + + double resultTime = JudgedObject.Result.TimeAbsolute; + + LifetimeStart = resultTime; + + ApplyTransformsAt(double.MinValue, true); + ClearTransforms(true); + + using (BeginAbsoluteSequence(resultTime)) + (skinnable.Drawable as IAnimatableHitExplosion)?.Animate(JudgedObject); + + if (secondHitTime != null) + { + using (BeginAbsoluteSequence(secondHitTime.Value)) + { + this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); + (skinnable.Drawable as IAnimatableHitExplosion)?.AnimateSecondHit(); + } + } + + LifetimeEnd = skinnable.Drawable.LatestTransformEndTime; } private static TaikoSkinComponents getComponentName(HitResult result) @@ -68,12 +116,10 @@ namespace osu.Game.Rulesets.Taiko.UI throw new ArgumentOutOfRangeException(nameof(result), $"Invalid result type: {result}"); } - /// - /// Transforms this hit explosion to visualise a secondary hit. - /// - public void VisualiseSecondHit() + public void VisualiseSecondHit(JudgementResult judgementResult) { - this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); + secondHitTime = judgementResult.TimeAbsolute; + runAnimation(); } } } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs new file mode 100644 index 0000000000..badf34554c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// Pool for hit explosions of a specific type. + /// + internal class HitExplosionPool : DrawablePool + { + private readonly HitResult hitResult; + + public HitExplosionPool(HitResult hitResult) + : base(15) + { + this.hitResult = hitResult; + } + + protected override HitExplosion CreateNewDrawable() => new HitExplosion(hitResult); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs new file mode 100644 index 0000000000..cf0f5f9fb6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.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.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// A skinnable element of a hit explosion that supports playing an animation from the current point in time. + /// + public interface IAnimatableHitExplosion + { + /// + /// Shows the hit explosion for the supplied . + /// + void Animate(DrawableHitObject drawableHitObject); + + /// + /// Transforms the hit explosion to visualise a secondary hit. + /// + void AnimateSecondHit(); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 148ec7755e..46dafc3a30 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -2,10 +2,12 @@ // 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.Containers; +using osu.Framework.Graphics.Pooling; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; @@ -17,6 +19,7 @@ using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Skinning; using osuTK; @@ -38,6 +41,9 @@ namespace osu.Game.Rulesets.Taiko.UI internal Drawable HitTarget; private SkinnableDrawable mascot; + private readonly IDictionary> judgementPools = new Dictionary>(); + private readonly IDictionary explosionPools = new Dictionary(); + private ProxyContainer topLevelHitContainer; private Container rightArea; private Container leftArea; @@ -159,6 +165,17 @@ namespace osu.Game.Rulesets.Taiko.UI RegisterPool(5); RegisterPool(100); + + var hitWindows = new TaikoHitWindows(); + + foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => hitWindows.IsHitResultAllowed(r))) + { + judgementPools.Add(result, new DrawablePool(15)); + explosionPools.Add(result, new HitExplosionPool(result)); + } + + AddRangeInternal(judgementPools.Values); + AddRangeInternal(explosionPools.Values); } protected override void LoadComplete() @@ -270,7 +287,7 @@ namespace osu.Game.Rulesets.Taiko.UI { case TaikoStrongJudgement _: if (result.IsHit) - hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(); + hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(result); break; case TaikoDrumRollTickJudgement _: @@ -283,13 +300,15 @@ namespace osu.Game.Rulesets.Taiko.UI break; default: - judgementContainer.Add(new DrawableTaikoJudgement(result, judgedObject) + judgementContainer.Add(judgementPools[result.Type].Get(j => { - Anchor = result.IsHit ? Anchor.TopLeft : Anchor.CentreLeft, - Origin = result.IsHit ? Anchor.BottomCentre : Anchor.Centre, - RelativePositionAxes = Axes.X, - X = result.IsHit ? judgedObject.Position.X : 0, - }); + j.Apply(result, judgedObject); + + j.Anchor = result.IsHit ? Anchor.TopLeft : Anchor.CentreLeft; + j.Origin = result.IsHit ? Anchor.BottomCentre : Anchor.Centre; + j.RelativePositionAxes = Axes.X; + j.X = result.IsHit ? judgedObject.Position.X : 0; + })); var type = (judgedObject.HitObject as Hit)?.Type ?? HitType.Centre; addExplosion(judgedObject, result.Type, type); @@ -302,7 +321,8 @@ namespace osu.Game.Rulesets.Taiko.UI private void addExplosion(DrawableHitObject drawableObject, HitResult result, HitType type) { - hitExplosionContainer.Add(new HitExplosion(drawableObject, result)); + hitExplosionContainer.Add(explosionPools[result] + .Get(explosion => explosion.Apply(drawableObject))); if (drawableObject.HitObject.Kiai) kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); } diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index c44ed69c4d..c3d9cb5875 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -20,6 +20,9 @@ + + $(NoWarn);CA2007 + %(RecursiveDir)%(Filename)%(Extension) @@ -69,5 +72,9 @@ osu.Game + + + + - \ No newline at end of file + diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index ca68369ebb..97df9b2cd5 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -21,6 +21,9 @@ %(RecursiveDir)%(Filename)%(Extension) + + $(NoWarn);CA2007 + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} @@ -45,6 +48,7 @@ + - \ No newline at end of file + diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 7bee580863..bcde899789 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -129,5 +129,25 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(3456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); } } + + [Test] + public void TestDecodeOutOfRangeLoopAnimationType() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("animation-types.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer foreground = storyboard.Layers.Single(l => l.Depth == 0); + Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[0]).LoopType); + Assert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[1]).LoopType); + Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[2]).LoopType); + Assert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[3]).LoopType); + Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[4]).LoopType); + Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[5]).LoopType); + } + } } } diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index c32e359de6..0c35e9471d 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -852,6 +852,21 @@ namespace osu.Game.Tests.Beatmaps.IO } } + public static async Task LoadQuickOszIntoOsu(OsuGameBase osu) + { + var temp = TestResources.GetQuickTestBeatmapForImport(); + + var manager = osu.Dependencies.Get(); + + var importedSet = await manager.Import(new ImportTask(temp)); + + ensureLoaded(osu); + + waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); + + return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.ID); + } + public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) { var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 600c820ce1..b80da928c8 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -21,6 +21,27 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(36, result.Links[0].Length); } + [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456")] + [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456?whatever")] + [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123/456")] + [TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc/def")] + [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")] + [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")] + [TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc")] + 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); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual(expectedAction, result.Links[0].Action); + Assert.AreEqual(expectedArg, result.Links[0].Argument); + if (expectedAction == LinkAction.External) + Assert.AreEqual(link, result.Links[0].Url); + } + [Test] public void TestMultipleComplexLinks() { diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index de46f9d1cf..3ded3009bd 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -121,7 +121,7 @@ namespace osu.Game.Tests.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) { diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 38cb6729c3..10a1a13ba0 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.IO; using osu.Game.Rulesets; @@ -35,7 +36,7 @@ namespace osu.Game.Tests.Gameplay public void TestRetrieveTopLevelSample() { ISkin skin = null; - SampleChannel channel = null; + Sample channel = null; AddStep("create skin", () => skin = new TestSkin("test-sample", this)); AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("test-sample"))); @@ -47,7 +48,7 @@ namespace osu.Game.Tests.Gameplay public void TestRetrieveSampleInSubFolder() { ISkin skin = null; - SampleChannel channel = null; + Sample channel = null; AddStep("create skin", () => skin = new TestSkin("folder/test-sample", this)); AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("folder/test-sample"))); @@ -90,6 +91,7 @@ namespace osu.Game.Tests.Gameplay public void TestSamplePlaybackWithRateMods(Type expectedMod, double expectedRate) { GameplayClockContainer gameplayContainer = null; + StoryboardSampleInfo sampleInfo = null; TestDrawableStoryboardSample sample = null; Mod testedMod = Activator.CreateInstance(expectedMod) as Mod; @@ -101,7 +103,7 @@ namespace osu.Game.Tests.Gameplay break; case ModTimeRamp m: - m.InitialRate.Value = m.FinalRate.Value = expectedRate; + m.FinalRate.Value = m.InitialRate.Value = expectedRate; break; } @@ -117,7 +119,7 @@ namespace osu.Game.Tests.Gameplay Child = beatmapSkinSourceContainer }); - beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) + beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(sampleInfo = new StoryboardSampleInfo("test-sample", 1, 1)) { Clock = gameplayContainer.GameplayClock }); @@ -125,7 +127,10 @@ namespace osu.Game.Tests.Gameplay AddStep("start", () => gameplayContainer.Start()); - AddAssert("sample playback rate matches mod rates", () => sample.ChildrenOfType().First().AggregateFrequency.Value == expectedRate); + AddAssert("sample playback rate matches mod rates", () => + testedMod != null && Precision.AlmostEquals( + sample.ChildrenOfType().First().AggregateFrequency.Value, + ((IApplicableToRate)testedMod).ApplyToRate(sampleInfo.StartTime))); } private class TestSkin : LegacySkin diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index b90382488f..27cece42e8 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -88,7 +88,7 @@ namespace osu.Game.Tests.Input => AddStep($"make window {mode}", () => frameworkConfigManager.GetBindable(FrameworkSetting.WindowMode).Value = mode); private void setGameSideModeTo(OsuConfineMouseMode mode) - => AddStep($"set {mode} game-side", () => Game.LocalConfig.Set(OsuSetting.ConfineMouseMode, mode)); + => AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode)); private void setLocalUserPlayingTo(bool playing) => AddStep($"local user {(playing ? "playing" : "not playing")}", () => Game.LocalUserPlaying.Value = playing); diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs new file mode 100644 index 0000000000..7dcaabca3d --- /dev/null +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Utils; + +namespace osu.Game.Tests.Mods +{ + [TestFixture] + public class ModUtilsTest + { + [Test] + public void TestModIsCompatibleByItself() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object })); + } + + [Test] + public void TestIncompatibleThroughTopLevel() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + + mod1.Setup(m => m.IncompatibleMods).Returns(new[] { mod2.Object.GetType() }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False); + } + + [Test] + public void TestMultiModIncompatibleWithTopLevel() + { + var mod1 = new Mock(); + + // The nested mod. + var mod2 = new Mock(); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { mod1.Object.GetType() }); + + var multiMod = new MultiMod(new MultiMod(mod2.Object)); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod1.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, multiMod }), Is.False); + } + + [Test] + public void TestTopLevelIncompatibleWithMultiMod() + { + // The nested mod. + var mod1 = new Mock(); + var multiMod = new MultiMod(new MultiMod(mod1.Object)); + + var mod2 = new Mock(); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(CustomMod1) }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, multiMod }), Is.False); + } + + [Test] + public void TestCompatibleMods() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.True); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.True); + } + + [Test] + public void TestIncompatibleThroughBaseType() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(Mod) }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False); + } + + [Test] + public void TestAllowedThroughMostDerivedType() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { mod.Object.GetType() })); + } + + [Test] + public void TestNotAllowedThroughBaseType() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { typeof(Mod) }), Is.False); + } + + private static readonly object[] invalid_mod_test_scenarios = + { + // incompatible pair. + new object[] + { + new Mod[] { new OsuModDoubleTime(), new OsuModHalfTime() }, + new[] { typeof(OsuModDoubleTime), typeof(OsuModHalfTime) } + }, + // incompatible pair with derived class. + new object[] + { + new Mod[] { new OsuModNightcore(), new OsuModHalfTime() }, + new[] { typeof(OsuModNightcore), typeof(OsuModHalfTime) } + }, + // system mod. + new object[] + { + new Mod[] { new OsuModDoubleTime(), new OsuModTouchDevice() }, + new[] { typeof(OsuModTouchDevice) } + }, + // multi mod. + new object[] + { + new Mod[] { new MultiMod(new OsuModHalfTime()), new OsuModHalfTime() }, + new[] { typeof(MultiMod) } + }, + // valid pair. + new object[] + { + new Mod[] { new OsuModDoubleTime(), new OsuModHardRock() }, + null + } + }; + + [TestCaseSource(nameof(invalid_mod_test_scenarios))] + public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) + { + bool isValid = ModUtils.CheckValidForGameplay(inputMods, out var invalid); + + Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + + if (isValid) + Assert.IsNull(invalid); + else + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + } + + public abstract class CustomMod1 : Mod + { + } + + public abstract class CustomMod2 : Mod + { + } + } +} diff --git a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs new file mode 100644 index 0000000000..883c9d1ac2 --- /dev/null +++ b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs @@ -0,0 +1,44 @@ +// 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.Bindables; +using osu.Game.Configuration; + +namespace osu.Game.Tests.Mods +{ + [TestFixture] + public class SettingsSourceAttributeTest + { + [Test] + public void TestOrdering() + { + var objectWithSettings = new ClassWithSettings(); + + var orderedSettings = objectWithSettings.GetOrderedSettingsSourceProperties().ToArray(); + + Assert.That(orderedSettings, Has.Length.EqualTo(4)); + + Assert.That(orderedSettings[0].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.FirstSetting))); + Assert.That(orderedSettings[1].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.SecondSetting))); + Assert.That(orderedSettings[2].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.ThirdSetting))); + Assert.That(orderedSettings[3].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.UnorderedSetting))); + } + + private class ClassWithSettings + { + [SettingSource("Unordered setting", "Should be last")] + public BindableFloat UnorderedSetting { get; set; } = new BindableFloat(); + + [SettingSource("Second setting", "Another description", 2)] + public BindableBool SecondSetting { get; set; } = new BindableBool(); + + [SettingSource("First setting", "A description", 1)] + public BindableDouble FirstSetting { get; set; } = new BindableDouble(); + + [SettingSource("Third setting", "Yet another description", 3)] + public BindableInt ThirdSetting { get; set; } = new BindableInt(); + } + } +} diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index b0f9768e09..6796344f24 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -47,7 +47,7 @@ namespace osu.Game.Tests.NonVisual using (var host = new CustomTestHeadlessGameHost()) { using (var storageConfig = new StorageConfigManager(host.InitialStorage)) - storageConfig.Set(StorageConfig.FullPath, customPath); + storageConfig.SetValue(StorageConfig.FullPath, customPath); try { @@ -73,7 +73,7 @@ namespace osu.Game.Tests.NonVisual using (var host = new CustomTestHeadlessGameHost()) { using (var storageConfig = new StorageConfigManager(host.InitialStorage)) - storageConfig.Set(StorageConfig.FullPath, customPath); + storageConfig.SetValue(StorageConfig.FullPath, customPath); try { diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 5c7adb3f49..1c0bfd56dd 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -212,7 +212,7 @@ namespace osu.Game.Tests.NonVisual throw new NotImplementedException(); } - protected override Skill[] CreateSkills(IBeatmap beatmap) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 24a0a662ba..8ff2743b6a 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -4,8 +4,10 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering { @@ -214,5 +216,31 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(filtered, carouselItem.Filtered.Value); } + + [Test] + public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria) + { + var beatmap = getExampleBeatmap(); + + var customCriteria = matchCustomCriteria is bool match ? new CustomCriteria(match) : null; + var criteria = new FilterCriteria { RulesetCriteria = customCriteria }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value); + } + + private class CustomCriteria : IRulesetFilterCriteria + { + private readonly bool match; + + public CustomCriteria(bool shouldMatch) + { + match = shouldMatch; + } + + public bool Matches(BeatmapInfo beatmap) => match; + public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) => false; + } } } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index d15682b1eb..49389e67aa 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -4,7 +4,9 @@ using System; using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering { @@ -194,5 +196,63 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(1, filterCriteria.SearchTerms.Length); Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm); } + + [Test] + public void TestOperatorParsing() + { + const string query = "artist=>=bad")] + [TestCase("divisor true; + + public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) + { + if (key == "custom" && op == Operator.Equal) + { + CustomValue = value; + return true; + } + + return false; + } + } } } diff --git a/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs new file mode 100644 index 0000000000..10216c3339 --- /dev/null +++ b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.NonVisual +{ + [HeadlessTest] + public class OngoingOperationTrackerTest : OsuTestScene + { + private OngoingOperationTracker tracker; + private IBindable operationInProgress; + + [SetUpSteps] + public void SetUp() + { + AddStep("create tracker", () => Child = tracker = new OngoingOperationTracker()); + AddStep("bind to operation status", () => operationInProgress = tracker.InProgress.GetBoundCopy()); + } + + [Test] + public void TestOperationTracking() + { + IDisposable firstOperation = null; + IDisposable secondOperation = null; + + AddStep("begin first operation", () => firstOperation = tracker.BeginOperation()); + AddAssert("first operation in progress", () => operationInProgress.Value); + + AddStep("cannot start another operation", + () => Assert.Throws(() => tracker.BeginOperation())); + + AddStep("end first operation", () => firstOperation.Dispose()); + AddAssert("first operation is ended", () => !operationInProgress.Value); + + AddStep("start second operation", () => secondOperation = tracker.BeginOperation()); + AddAssert("second operation in progress", () => operationInProgress.Value); + + AddStep("dispose first operation again", () => firstOperation.Dispose()); + AddAssert("second operation still in progress", () => operationInProgress.Value); + + AddStep("dispose second operation", () => secondOperation.Dispose()); + AddAssert("second operation is ended", () => !operationInProgress.Value); + } + + [Test] + public void TestOperationDisposalAfterTracker() + { + IDisposable operation = null; + + AddStep("begin operation", () => operation = tracker.BeginOperation()); + AddStep("dispose tracker", () => tracker.Expire()); + AddStep("end operation", () => operation.Dispose()); + AddAssert("operation is ended", () => !operationInProgress.Value); + } + + [Test] + public void TestOperationDisposalAfterScreenExit() + { + TestScreenWithTracker screen = null; + OsuScreenStack stack; + IDisposable operation = null; + + AddStep("create screen with tracker", () => + { + Child = stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both + }; + + stack.Push(screen = new TestScreenWithTracker()); + }); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("begin operation", () => operation = screen.OngoingOperationTracker.BeginOperation()); + AddAssert("operation in progress", () => screen.OngoingOperationTracker.InProgress.Value); + + AddStep("dispose after screen exit", () => + { + screen.Exit(); + operation.Dispose(); + }); + AddAssert("operation ended", () => !screen.OngoingOperationTracker.InProgress.Value); + } + + private class TestScreenWithTracker : OsuScreen + { + public OngoingOperationTracker OngoingOperationTracker { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = OngoingOperationTracker = new OngoingOperationTracker(); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs index a5c937119e..da004b9088 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.NonVisual.Skinning } public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); public IBindable GetConfig(TLookup lookup) => throw new NotSupportedException(); } diff --git a/osu.Game.Tests/NonVisual/TaskChainTest.cs b/osu.Game.Tests/NonVisual/TaskChainTest.cs new file mode 100644 index 0000000000..d83eaafe20 --- /dev/null +++ b/osu.Game.Tests/NonVisual/TaskChainTest.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Game.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class TaskChainTest + { + private TaskChain taskChain; + private int currentTask; + private CancellationTokenSource globalCancellationToken; + + [SetUp] + public void Setup() + { + globalCancellationToken = new CancellationTokenSource(); + taskChain = new TaskChain(); + currentTask = 0; + } + + [TearDown] + public void TearDown() + { + globalCancellationToken?.Cancel(); + } + + [Test] + public async Task TestChainedTasksRunSequentially() + { + var task1 = addTask(); + var task2 = addTask(); + var task3 = addTask(); + + task3.mutex.Set(); + task2.mutex.Set(); + task1.mutex.Set(); + + 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)); + } + + [Test] + public async Task TestChainedTaskWithIntermediateCancelRunsInSequence() + { + var task1 = addTask(); + var task2 = addTask(); + var task3 = addTask(); + + // Cancel task2, allow task3 to complete. + task2.cancellation.Cancel(); + task2.mutex.Set(); + task3.mutex.Set(); + + // Allow task3 to potentially complete. + Thread.Sleep(1000); + + // Allow task1 to complete. + task1.mutex.Set(); + + // Wait on both tasks. + await Task.WhenAll(task1.task, task3.task); + + Assert.That(task1.task.Result, Is.EqualTo(1)); + Assert.That(task2.task.IsCompleted, Is.False); + Assert.That(task3.task.Result, Is.EqualTo(2)); + } + + [Test] + public async Task TestChainedTaskDoesNotCompleteBeforeChildTasks() + { + var mutex = new ManualResetEventSlim(false); + + var task = taskChain.Add(async () => await Task.Run(() => mutex.Wait(globalCancellationToken.Token))); + + // Allow task to potentially complete + Thread.Sleep(1000); + + Assert.That(task.IsCompleted, Is.False); + + // Allow the task to complete. + mutex.Set(); + + await task; + } + + private (Task task, ManualResetEventSlim mutex, CancellationTokenSource cancellation) addTask() + { + var mutex = new ManualResetEventSlim(false); + var completionSource = new TaskCompletionSource(); + + var cancellationSource = new CancellationTokenSource(); + var token = CancellationTokenSource.CreateLinkedTokenSource(cancellationSource.Token, globalCancellationToken.Token); + + taskChain.Add(() => + { + mutex.Wait(globalCancellationToken.Token); + completionSource.SetResult(Interlocked.Increment(ref currentTask)); + }, token.Token); + + return (completionSource.Task, mutex, cancellationSource); + } + } +} diff --git a/osu.Game.Tests/Online/TestAPIModSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs similarity index 85% rename from osu.Game.Tests/Online/TestAPIModSerialization.cs rename to osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 5948582d77..ab24a72a12 100644 --- a/osu.Game.Tests/Online/TestAPIModSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -16,7 +16,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Online { [TestFixture] - public class TestAPIModSerialization + public class TestAPIModJsonSerialization { [Test] public void TestAcronymIsPreserved() @@ -68,12 +68,29 @@ namespace osu.Game.Tests.Online Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); } + [Test] + public void TestDeserialiseDifficultyAdjustModWithExtendedLimits() + { + var apiMod = new APIMod(new TestModDifficultyAdjust + { + OverallDifficulty = { Value = 11 }, + ExtendedLimits = { Value = true } + }); + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + var converted = (TestModDifficultyAdjust)deserialised.ToMod(new TestRuleset()); + + Assert.That(converted.ExtendedLimits.Value, Is.True); + Assert.That(converted.OverallDifficulty.Value, Is.EqualTo(11)); + } + private class TestRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) => new Mod[] { new TestMod(), new TestModTimeRamp(), + new TestModDifficultyAdjust() }; public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); @@ -135,5 +152,9 @@ namespace osu.Game.Tests.Online Value = true }; } + + private class TestModDifficultyAdjust : ModDifficultyAdjust + { + } } } diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs new file mode 100644 index 0000000000..74db477cfc --- /dev/null +++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using MessagePack; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + public class TestAPIModMessagePackSerialization + { + [Test] + public void TestAcronymIsPreserved() + { + var apiMod = new APIMod(new TestMod()); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + + Assert.That(deserialized.Acronym, Is.EqualTo(apiMod.Acronym)); + } + + [Test] + public void TestRawSettingIsPreserved() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + + Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(2.0)); + } + + [Test] + public void TestConvertedModHasCorrectSetting() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + var converted = (TestMod)deserialized.ToMod(new TestRuleset()); + + Assert.That(converted.TestSetting.Value, Is.EqualTo(2)); + } + + [Test] + public void TestDeserialiseTimeRampMod() + { + // Create the mod with values different from default. + var apiMod = new APIMod(new TestModTimeRamp + { + AdjustPitch = { Value = false }, + InitialRate = { Value = 1.25 }, + FinalRate = { Value = 0.25 } + }); + + var deserialised = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset()); + + Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false)); + Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25)); + Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); + } + + [Test] + public void TestDeserialiseEnumMod() + { + var apiMod = new APIMod(new TestModEnum { TestSetting = { Value = TestEnum.Value2 } }); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + + Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(1)); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => new Mod[] + { + new TestMod(), + new TestModTimeRamp(), + }; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new System.NotImplementedException(); + + public override string Description { get; } = string.Empty; + public override string ShortName { get; } = string.Empty; + } + + private class TestMod : Mod + { + public override string Name => "Test Mod"; + public override string Acronym => "TM"; + public override double ScoreMultiplier => 1; + + [SettingSource("Test")] + public BindableNumber TestSetting { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Precision = 0.01, + }; + } + + private class TestModTimeRamp : ModTimeRamp + { + public override string Name => "Test Mod"; + public override string Acronym => "TMTR"; + public override double ScoreMultiplier => 1; + + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber InitialRate { get; } = new BindableDouble + { + MinValue = 1, + MaxValue = 2, + Default = 1.5, + Value = 1.5, + Precision = 0.01, + }; + + [SettingSource("Final rate", "The speed increase to ramp towards")] + public override BindableNumber FinalRate { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 1, + Default = 0.5, + Value = 0.5, + Precision = 0.01, + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public override BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + } + + private class TestModEnum : Mod + { + public override string Name => "Test Mod"; + public override string Acronym => "TM"; + public override double ScoreMultiplier => 1; + + [SettingSource("Test")] + public Bindable TestSetting { get; } = new Bindable(); + } + + private enum TestEnum + { + Value1 = 0, + Value2 = 1, + Value3 = 2 + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs new file mode 100644 index 0000000000..8c30802ce3 --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -0,0 +1,188 @@ +// 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.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene + { + private RulesetStore rulesets; + private TestBeatmapManager beatmaps; + + private string testBeatmapFile; + private BeatmapInfo testBeatmapInfo; + private BeatmapSetInfo testBeatmapSet; + + private readonly Bindable selectedItem = new Bindable(); + private OnlinePlayBeatmapAvailablilityTracker availablilityTracker; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, ContextFactory, rulesets, API, audio, host, Beatmap.Default)); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + beatmaps.AllowImport = new TaskCompletionSource(); + + testBeatmapFile = TestResources.GetQuickTestBeatmapForImport(); + + testBeatmapInfo = getTestBeatmapInfo(testBeatmapFile); + testBeatmapSet = testBeatmapInfo.BeatmapSet; + + var existing = beatmaps.QueryBeatmapSet(s => s.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID); + if (existing != null) + beatmaps.Delete(existing); + + selectedItem.Value = new PlaylistItem + { + Beatmap = { Value = testBeatmapInfo }, + Ruleset = { Value = testBeatmapInfo.Ruleset }, + }; + + Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = selectedItem, } + }; + }); + + [Test] + public void TestBeatmapDownloadingFlow() + { + AddAssert("ensure beatmap unavailable", () => !beatmaps.IsAvailableLocally(testBeatmapSet)); + addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); + + AddStep("start downloading", () => beatmaps.Download(testBeatmapSet)); + addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f)); + + AddStep("set progress 40%", () => ((TestDownloadRequest)beatmaps.GetExistingDownload(testBeatmapSet)).SetProgress(0.4f)); + addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f)); + + AddStep("finish download", () => ((TestDownloadRequest)beatmaps.GetExistingDownload(testBeatmapSet)).TriggerSuccess(testBeatmapFile)); + addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing); + + AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddUntilStep("wait for import", () => beatmaps.CurrentImportTask?.IsCompleted == true); + addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); + } + + [Test] + public void TestTrackerRespectsSoftDeleting() + { + AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).Wait()); + addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); + + AddStep("delete beatmap", () => beatmaps.Delete(beatmaps.QueryBeatmapSet(b => b.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID))); + addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); + + AddStep("undelete beatmap", () => beatmaps.Undelete(beatmaps.QueryBeatmapSet(b => b.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID))); + addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); + } + + [Test] + public void TestTrackerRespectsChecksum() + { + AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + + AddStep("import altered beatmap", () => + { + beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + }); + addAvailabilityCheckStep("state still not downloaded", BeatmapAvailability.NotDownloaded); + + AddStep("recreate tracker", () => Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = selectedItem } + }); + addAvailabilityCheckStep("state not downloaded as well", BeatmapAvailability.NotDownloaded); + } + + private void addAvailabilityCheckStep(string description, Func expected) + { + AddAssert(description, () => availablilityTracker.Availability.Value.Equals(expected.Invoke())); + } + + private static BeatmapInfo getTestBeatmapInfo(string archiveFile) + { + BeatmapInfo info; + + using (var archive = new ZipArchiveReader(File.OpenRead(archiveFile))) + using (var stream = archive.GetStream("Soleily - Renatus (Gamu) [Insane].osu")) + using (var reader = new LineBufferedReader(stream)) + { + var decoder = Decoder.GetDecoder(reader); + var beatmap = decoder.Decode(reader); + + info = beatmap.BeatmapInfo; + info.BeatmapSet.Beatmaps = new List { info }; + info.BeatmapSet.Metadata = info.Metadata; + info.MD5Hash = stream.ComputeMD5Hash(); + info.Hash = stream.ComputeSHA2Hash(); + } + + return info; + } + + private class TestBeatmapManager : BeatmapManager + { + public TaskCompletionSource AllowImport = new TaskCompletionSource(); + + public Task CurrentImportTask { get; private set; } + + protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) + => new TestDownloadRequest(set); + + public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) + : base(storage, contextFactory, rulesets, api, audioManager, host, defaultBeatmap, performOnlineLookups) + { + } + + public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + await AllowImport.Task; + return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)); + } + } + + private class TestDownloadRequest : ArchiveDownloadRequest + { + public new void SetProgress(float progress) => base.SetProgress(progress); + public new void TriggerSuccess(string filename) => base.TriggerSuccess(filename); + + public TestDownloadRequest(BeatmapSetInfo model) + : base(model) + { + } + + protected override string Target => null; + } + } +} diff --git a/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs new file mode 100644 index 0000000000..82ce588c6f --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public class StatefulMultiplayerClientTest : MultiplayerTestScene + { + [Test] + public void TestUserAddedOnJoin() + { + var user = new User { Id = 33 }; + + AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + } + + [Test] + public void TestUserRemovedOnLeave() + { + var user = new User { Id = 44 }; + + AddStep("add user", () => Client.AddUser(user)); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + + AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); + AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); + } + } +} diff --git a/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual_quick.osz b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual_quick.osz new file mode 100644 index 0000000000..e9f5fb0328 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual_quick.osz differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index e882229570..cef0532f9d 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -15,6 +15,28 @@ namespace osu.Game.Tests.Resources public static Stream GetTestBeatmapStream(bool virtualTrack = false) => OpenResource($"Archives/241526 Soleily - Renatus{(virtualTrack ? "_virtual" : "")}.osz"); + /// + /// Retrieve a path to a copy of a shortened (~10 second) beatmap archive with a virtual track. + /// + /// + /// This is intended for use in tests which need to run to completion as soon as possible and don't need to test a full length beatmap. + /// A path to a copy of a beatmap archive (osz). Should be deleted after use. + public static string GetQuickTestBeatmapForImport() + { + var tempPath = Path.GetTempFileName() + ".osz"; + using (var stream = OpenResource("Archives/241526 Soleily - Renatus_virtual_quick.osz")) + using (var newFile = File.Create(tempPath)) + stream.CopyTo(newFile); + + Assert.IsTrue(File.Exists(tempPath)); + return tempPath; + } + + /// + /// Retrieve a path to a copy of a full-fledged beatmap archive. + /// + /// Whether the audio track should be virtual. + /// A path to a copy of a beatmap archive (osz). Should be deleted after use. public static string GetTestBeatmapForImport(bool virtualTrack = false) { var tempPath = Path.GetTempFileName() + ".osz"; diff --git a/osu.Game.Tests/Resources/animation-types.osb b/osu.Game.Tests/Resources/animation-types.osb new file mode 100644 index 0000000000..82233b7d30 --- /dev/null +++ b/osu.Game.Tests/Resources/animation-types.osb @@ -0,0 +1,9 @@ +osu file format v14 + +[Events] +Animation,Foreground,Centre,"forever-string.png",330,240,10,108,LoopForever +Animation,Foreground,Centre,"once-string.png",330,240,10,108,LoopOnce +Animation,Foreground,Centre,"forever-number.png",330,240,10,108,0 +Animation,Foreground,Centre,"once-number.png",330,240,10,108,1 +Animation,Foreground,Centre,"undefined-number.png",330,240,10,108,16 +Animation,Foreground,Centre,"omitted.png",330,240,10,108 diff --git a/osu.Game.Tests/Resources/skin-with-space.ini b/osu.Game.Tests/Resources/skin-with-space.ini new file mode 100644 index 0000000000..3e64257a3e --- /dev/null +++ b/osu.Game.Tests/Resources/skin-with-space.ini @@ -0,0 +1,2 @@ +[General] +Version: 2 \ No newline at end of file diff --git a/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs new file mode 100644 index 0000000000..4b9f2181dc --- /dev/null +++ b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs @@ -0,0 +1,113 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Audio.Track; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Rulesets.Mods +{ + [TestFixture] + public class ModTimeRampTest + { + private const double start_time = 1000; + private const double duration = 9000; + + private TrackVirtual track; + + [SetUp] + public void SetUp() + { + track = new TrackVirtual(20_000); + } + + [TestCase(0, 1)] + [TestCase(start_time, 1)] + [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS / 2, 1.25)] + [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS, 1.5)] + [TestCase(start_time + duration, 1.5)] + [TestCase(15000, 1.5)] + public void TestModWindUp(double time, double expectedRate) + { + var beatmap = createSingleSpinnerBeatmap(); + var mod = new ModWindUp(); + mod.ApplyToBeatmap(beatmap); + mod.ApplyToTrack(track); + + seekTrackAndUpdateMod(mod, time); + + Assert.That(mod.SpeedChange.Value, Is.EqualTo(expectedRate)); + } + + [TestCase(0, 1)] + [TestCase(start_time, 1)] + [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS / 2, 0.75)] + [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS, 0.5)] + [TestCase(start_time + duration, 0.5)] + [TestCase(15000, 0.5)] + public void TestModWindDown(double time, double expectedRate) + { + var beatmap = createSingleSpinnerBeatmap(); + var mod = new ModWindDown + { + FinalRate = { Value = 0.5 } + }; + mod.ApplyToBeatmap(beatmap); + mod.ApplyToTrack(track); + + seekTrackAndUpdateMod(mod, time); + + Assert.That(mod.SpeedChange.Value, Is.EqualTo(expectedRate)); + } + + [TestCase(0, 1)] + [TestCase(start_time, 1)] + [TestCase(2 * start_time, 1.5)] + public void TestZeroDurationMap(double time, double expectedRate) + { + var beatmap = createSingleObjectBeatmap(); + var mod = new ModWindUp(); + mod.ApplyToBeatmap(beatmap); + mod.ApplyToTrack(track); + + seekTrackAndUpdateMod(mod, time); + + Assert.That(mod.SpeedChange.Value, Is.EqualTo(expectedRate)); + } + + private void seekTrackAndUpdateMod(ModTimeRamp mod, double time) + { + track.Seek(time); + // update the mod via a fake playfield to re-calculate the current rate. + mod.Update(null); + } + + private static Beatmap createSingleSpinnerBeatmap() + { + return new Beatmap + { + HitObjects = + { + new Spinner + { + StartTime = start_time, + Duration = duration + } + } + }; + } + + private static Beatmap createSingleObjectBeatmap() + { + return new Beatmap + { + HitObjects = + { + new HitCircle { StartTime = start_time } + } + }; + } + } +} diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index 987a5812db..f421a30283 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -105,9 +105,9 @@ namespace osu.Game.Tests.Rulesets IsDisposed = true; } - public SampleChannel Get(string name) => null; + public Sample Get(string name) => null; - public Task GetAsync(string name) => null; + public Task GetAsync(string name) => null; public Stream GetStream(string name) => null; @@ -118,9 +118,13 @@ namespace osu.Game.Tests.Rulesets public BindableNumber Frequency => throw new NotImplementedException(); public BindableNumber Tempo => throw new NotImplementedException(); - public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotImplementedException(); + public void BindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); - public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotImplementedException(); + public void UnbindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => throw new NotImplementedException(); + + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => throw new NotImplementedException(); public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index aedf26ee75..dcb866c99f 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -91,6 +91,15 @@ namespace osu.Game.Tests.Skins Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); } + [Test] + public void TestStripWhitespace() + { + var decoder = new LegacySkinDecoder(); + using (var resStream = TestResources.OpenResource("skin-with-space.ini")) + using (var stream = new LineBufferedReader(resStream)) + Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); + } + [Test] public void TestDecodeLatestVersion() { diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index ad5b3ec0f6..414f7d3f88 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -219,7 +219,7 @@ namespace osu.Game.Tests.Skins public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); - public SampleChannel GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); + public Sample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); } diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index fba0d92d4b..e7cf830db0 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Background public void SetUp() => Schedule(() => { // reset API response in statics to avoid test crosstalk. - statics.Set(Static.SeasonalBackgrounds, null); + statics.SetValue(Static.SeasonalBackgrounds, null); textureStore.PerformedLookups.Clear(); dummyAPI.SetState(APIState.Online); @@ -146,7 +146,7 @@ namespace osu.Game.Tests.Visual.Background }); private void setSeasonalBackgroundMode(SeasonalBackgroundMode mode) - => AddStep($"set seasonal mode to {mode}", () => config.Set(OsuSetting.SeasonalBackgroundMode, mode)); + => AddStep($"set seasonal mode to {mode}", () => config.SetValue(OsuSetting.SeasonalBackgroundMode, mode)); private void createLoader() => AddStep("create loader", () => diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 7ade7725d9..ba4d12b19f 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Background Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); - manager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + manager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); Beatmap.SetDefault(); } diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index fef1605f0c..eca857f9e5 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -38,13 +38,13 @@ namespace osu.Game.Tests.Visual.Collections Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); - beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); base.Content.AddRange(new Drawable[] { manager = new CollectionManager(LocalStorage), Content, - dialogOverlay = new DialogOverlay() + dialogOverlay = new DialogOverlay(), }); Dependencies.Cache(manager); @@ -134,6 +134,27 @@ namespace osu.Game.Tests.Visual.Collections assertCollectionName(0, "2"); } + [Test] + public void TestCollectionNameCollisions() + { + AddStep("add dropdown", () => + { + Add(new CollectionFilterDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0.4f, + } + ); + }); + AddStep("add two collections with same name", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + })); + } + [Test] public void TestRemoveCollectionViaButton() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs new file mode 100644 index 0000000000..390198be04 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs @@ -0,0 +1,81 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneEditorClock : EditorClockTestScene + { + public TestSceneEditorClock() + { + Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new TimeInfoContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 100) + }, + new PlaybackControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 100) + } + } + }); + } + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + // ensure that music controller does not change this beatmap due to it + // completing naturally as part of the test. + Beatmap.Disabled = true; + } + + [Test] + public void TestStopAtTrackEnd() + { + AddStep("reset clock", () => Clock.Seek(0)); + + AddStep("start clock", Clock.Start); + AddAssert("clock running", () => Clock.IsRunning); + + AddStep("seek near end", () => Clock.Seek(Clock.TrackLength - 250)); + AddUntilStep("clock stops", () => !Clock.IsRunning); + + AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + + AddStep("start clock again", Clock.Start); + AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + } + + [Test] + public void TestWrapWhenStoppedAtTrackEnd() + { + AddStep("reset clock", () => Clock.Seek(0)); + + AddStep("stop clock", Clock.Stop); + AddAssert("clock stopped", () => !Clock.IsRunning); + + AddStep("seek exactly to end", () => Clock.Seek(Clock.TrackLength)); + AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + + AddStep("start clock again", Clock.Start); + AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs index f182023c0e..2abc8a8dec 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs @@ -3,11 +3,11 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Graphics.Audio; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Editing { @@ -19,14 +19,14 @@ namespace osu.Game.Tests.Visual.Editing public void TestSlidingSampleStopsOnSeek() { DrawableSlider slider = null; - DrawableSample[] loopingSamples = null; - DrawableSample[] onceOffSamples = null; + PoolableSkinnableSample[] loopingSamples = null; + PoolableSkinnableSample[] onceOffSamples = null; AddStep("get first slider", () => { slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First(); - onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); - loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); + onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); + loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); }); AddStep("start playback", () => EditorClock.Start()); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index 3adc1bd425..94a9fd7b35 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -5,6 +5,8 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osuTK; @@ -13,6 +15,9 @@ namespace osu.Game.Tests.Visual.Editing [TestFixture] public class TestSceneEditorSummaryTimeline : EditorClockTestScene { + [Cached(typeof(EditorBeatmap))] + private readonly EditorBeatmap editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs new file mode 100644 index 0000000000..35f394fe1d --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osuTK; +using osuTK.Input; +using static osu.Game.Screens.Edit.Compose.Components.Timeline.TimelineHitObjectBlueprint; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneTimelineHitObjectBlueprint : TimelineTestScene + { + public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(Composer); + + [Test] + public void TestDisallowZeroDurationObjects() + { + DragBar dragBar; + + AddStep("add spinner", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Spinner + { + Position = new Vector2(256, 256), + StartTime = 150, + Duration = 500 + }); + }); + + AddStep("hold down drag bar", () => + { + // distinguishes between the actual drag bar and its "underlay shadow". + dragBar = this.ChildrenOfType().Single(bar => bar.HandlePositionalInput); + InputManager.MoveMouseTo(dragBar); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("try to drag bar past start", () => + { + var blueprint = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(blueprint.SelectionQuad.TopLeft - new Vector2(100, 0)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("object has non-zero duration", () => EditorBeatmap.HitObjects.OfType().Single().Duration > 0); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs index 63bb018d6e..1da6433707 100644 --- a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs +++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs @@ -23,22 +23,24 @@ namespace osu.Game.Tests.Visual.Editing protected HitObjectComposer Composer { get; private set; } + protected EditorBeatmap EditorBeatmap { get; private set; } + [BackgroundDependencyLoader] private void load(AudioManager audio) { Beatmap.Value = new WaveformTestBeatmap(audio); var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); - var editorBeatmap = new EditorBeatmap(playable); + EditorBeatmap = new EditorBeatmap(playable); - Dependencies.Cache(editorBeatmap); - Dependencies.CacheAs(editorBeatmap); + Dependencies.Cache(EditorBeatmap); + Dependencies.CacheAs(EditorBeatmap); Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer().With(d => d.Alpha = 0); AddRange(new Drawable[] { - editorBeatmap, + EditorBeatmap, Composer, new FillFlowContainer { @@ -108,8 +110,6 @@ namespace osu.Game.Tests.Visual.Editing [Resolved] private EditorClock editorClock { get; set; } - private bool started; - public StartStopButton() { BackgroundColour = Color4.SlateGray; @@ -121,18 +121,17 @@ namespace osu.Game.Tests.Visual.Editing private void onClick() { - if (started) - { + if (editorClock.IsRunning) editorClock.Stop(); - Text = "Start"; - } else - { editorClock.Start(); - Text = "Stop"; - } + } - started = !started; + protected override void Update() + { + base.Update(); + + Text = editorClock.IsRunning ? "Stop" : "Start"; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs index 1c55595c97..5a1a9d3d87 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddStep("show health", () => showHealth.Value = true); - AddStep("enable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddStep("enable layer", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); AddUntilStep("layer is visible", () => layer.IsPresent); } @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestLayerDisabledViaConfig() { - AddStep("disable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("disable layer", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); AddStep("set health to 0.10", () => layer.Current.Value = 0.1); AddUntilStep("layer is not visible", () => !layer.IsPresent); } @@ -81,19 +81,19 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("set health to 0.10", () => layer.Current.Value = 0.1); AddStep("don't show health", () => showHealth.Value = false); - AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); AddUntilStep("layer fade is invisible", () => !layer.IsPresent); AddStep("don't show health", () => showHealth.Value = false); - AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); AddUntilStep("layer fade is invisible", () => !layer.IsPresent); AddStep("show health", () => showHealth.Value = true); - AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); AddUntilStep("layer fade is invisible", () => !layer.IsPresent); AddStep("show health", () => showHealth.Value = true); - AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); AddUntilStep("layer fade is visible", () => layer.IsPresent); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 7c6a213fe2..6b3fc304e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Graphics.Audio; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -20,14 +19,14 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestAllSamplesStopDuringSeek() { DrawableSlider slider = null; - DrawableSample[] samples = null; + PoolableSkinnableSample[] samples = null; ISamplePlaybackDisabler sampleDisabler = null; AddUntilStep("get variables", () => { sampleDisabler = Player; slider = Player.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).FirstOrDefault(); - samples = slider?.ChildrenOfType().ToArray(); + samples = slider?.ChildrenOfType().ToArray(); return slider != null; }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index f9914e0193..3cefb8623f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -81,7 +81,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("get original config value", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); - AddStep("set hud to never show", () => config.Set(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); + AddStep("set hud to never show", () => config.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); AddUntilStep("wait for fade", () => !hideTarget.IsPresent); @@ -91,7 +91,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("stop trigering", () => InputManager.ReleaseKey(Key.ControlLeft)); AddUntilStep("wait for fade", () => !hideTarget.IsPresent); - AddStep("set original config value", () => config.Set(OsuSetting.HUDVisibilityMode, originalConfigValue)); + AddStep("set original config value", () => config.SetValue(OsuSetting.HUDVisibilityMode, originalConfigValue)); } [Test] @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("set keycounter visible false", () => { - config.Set(OsuSetting.KeyOverlay, false); + config.SetValue(OsuSetting.KeyOverlay, false); hudOverlay.KeyCounter.AlwaysVisible.Value = false; }); @@ -132,7 +132,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("hidetarget is visible", () => hideTarget.IsPresent); AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); - AddStep("return value", () => config.Set(OsuSetting.KeyOverlay, keyCounterVisibleValue)); + AddStep("return value", () => config.SetValue(OsuSetting.KeyOverlay, keyCounterVisibleValue)); } private void createNew(Action action = null) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 563d6be0da..dccde366c2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -46,11 +46,12 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(0, 0)] [TestCase(-1000, -1000)] [TestCase(-10000, -10000)] - public void TestStoryboardProducesCorrectStartTime(double firstStoryboardEvent, double expectedStartTime) + public void TestStoryboardProducesCorrectStartTimeSimpleAlpha(double firstStoryboardEvent, double expectedStartTime) { var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); + sprite.TimelineGroup.Alpha.Add(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); storyboard.GetLayer("Background").Add(sprite); @@ -64,6 +65,43 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + [TestCase(1000, 0, false)] + [TestCase(0, 0, false)] + [TestCase(-1000, -1000, false)] + [TestCase(-10000, -10000, false)] + [TestCase(1000, 0, true)] + [TestCase(0, 0, true)] + [TestCase(-1000, -1000, true)] + [TestCase(-10000, -10000, true)] + public void TestStoryboardProducesCorrectStartTimeFadeInAfterOtherEvents(double firstStoryboardEvent, double expectedStartTime, bool addEventToLoop) + { + var storyboard = new Storyboard(); + + var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); + + // these should be ignored as we have an alpha visibility blocker proceeding this command. + sprite.TimelineGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); + var loopGroup = sprite.AddLoop(-20000, 50); + loopGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); + + var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; + target.Alpha.Add(Easing.None, firstStoryboardEvent, 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); + + storyboard.GetLayer("Background").Add(sprite); + + loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); + + AddAssert($"first frame is {expectedStartTime}", () => + { + Debug.Assert(player.FirstFrameClockTime != null); + return Precision.AlmostEquals(player.FirstFrameClockTime.Value, expectedStartTime, lenience_ms); + }); + } + private void loadPlayerWithBeatmap(IBeatmap beatmap, Storyboard storyboard = null) { AddStep("create player", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 46dd91710a..bddc7ab731 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -56,9 +56,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseAndConfirm(); resume(); - confirmClockRunning(false); - confirmPauseOverlayShown(false); - + confirmPausedWithNoOverlay(); AddStep("click to resume", () => InputManager.Click(MouseButton.Left)); confirmClockRunning(true); @@ -71,15 +69,14 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for hitobjects", () => Player.HealthProcessor.Health.Value < 1); pauseAndConfirm(); - resume(); - confirmClockRunning(false); - confirmPauseOverlayShown(false); + confirmPausedWithNoOverlay(); pauseAndConfirm(); AddUntilStep("resume overlay is not active", () => Player.DrawableRuleset.ResumeOverlay.State.Value == Visibility.Hidden); confirmPaused(); + confirmNotExited(); } [Test] @@ -94,33 +91,54 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestPauseTooSoon() + public void TestUserPauseWhenPauseNotAllowed() + { + AddStep("disable pause support", () => Player.Configuration.AllowPause = false); + + pauseFromUserExitKey(); + confirmExited(); + } + + [Test] + public void TestUserPauseDuringCooldownTooSoon() { AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); pauseAndConfirm(); resume(); - pause(); + pauseFromUserExitKey(); - confirmClockRunning(true); - confirmPauseOverlayShown(false); + confirmResumed(); + confirmNotExited(); } [Test] - public void TestExitTooSoon() + public void TestQuickExitDuringCooldownTooSoon() + { + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + pauseAndConfirm(); + + resume(); + AddStep("pause via exit key", () => Player.ExitViaQuickExit()); + + confirmResumed(); + AddAssert("exited", () => !Player.IsCurrentScreen()); + } + + [Test] + public void TestExitSoonAfterResumeSucceeds() { AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); pauseAndConfirm(); resume(); - AddStep("exit too soon", () => Player.Exit()); + AddStep("exit quick", () => Player.Exit()); - confirmClockRunning(true); - confirmPauseOverlayShown(false); - - AddAssert("not exited", () => Player.IsCurrentScreen()); + confirmResumed(); + AddAssert("exited", () => !Player.IsCurrentScreen()); } [Test] @@ -131,22 +149,37 @@ namespace osu.Game.Tests.Visual.Gameplay confirmClockRunning(false); - pause(); - - confirmClockRunning(false); - confirmPauseOverlayShown(false); + AddStep("pause via forced pause", () => Player.Pause()); + confirmPausedWithNoOverlay(); AddAssert("fail overlay still shown", () => Player.FailOverlayVisible); exitAndConfirm(); } [Test] - public void TestExitFromFailedGameplay() + public void TestExitFromFailedGameplayAfterFailAnimation() { AddUntilStep("wait for fail", () => Player.HasFailed); - AddStep("exit", () => Player.Exit()); + AddUntilStep("wait for fail overlay shown", () => Player.FailOverlayVisible); + confirmClockRunning(false); + + AddStep("exit via user pause", () => Player.ExitViaPause()); + confirmExited(); + } + + [Test] + public void TestExitFromFailedGameplayDuringFailAnimation() + { + AddUntilStep("wait for fail", () => Player.HasFailed); + + // will finish the fail animation and show the fail/pause screen. + AddStep("attempt exit via pause key", () => Player.ExitViaPause()); + AddAssert("fail overlay shown", () => Player.FailOverlayVisible); + + // will actually exit. + AddStep("exit via pause key", () => Player.ExitViaPause()); confirmExited(); } @@ -245,7 +278,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void pauseAndConfirm() { - pause(); + pauseFromUserExitKey(); confirmPaused(); } @@ -257,7 +290,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void exitAndConfirm() { - AddUntilStep("player not exited", () => Player.IsCurrentScreen()); + confirmNotExited(); AddStep("exit", () => Player.Exit()); confirmExited(); confirmNoTrackAdjustments(); @@ -266,7 +299,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void confirmPaused() { confirmClockRunning(false); - AddAssert("player not exited", () => Player.IsCurrentScreen()); + confirmNotExited(); AddAssert("player not failed", () => !Player.HasFailed); AddAssert("pause overlay shown", () => Player.PauseOverlayVisible); } @@ -277,18 +310,22 @@ namespace osu.Game.Tests.Visual.Gameplay confirmPauseOverlayShown(false); } - private void confirmExited() + private void confirmPausedWithNoOverlay() { - AddUntilStep("player exited", () => !Player.IsCurrentScreen()); + confirmClockRunning(false); + confirmPauseOverlayShown(false); } + private void confirmExited() => AddUntilStep("player exited", () => !Player.IsCurrentScreen()); + private void confirmNotExited() => AddAssert("player not exited", () => Player.IsCurrentScreen()); + private void confirmNoTrackAdjustments() { AddAssert("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value == 1); } private void restart() => AddStep("restart", () => Player.Restart()); - private void pause() => AddStep("pause", () => Player.Pause()); + private void pauseFromUserExitKey() => AddStep("user pause", () => Player.ExitViaPause()); private void resume() => AddStep("resume", () => Player.Resume()); private void confirmPauseOverlayShown(bool isShown) => @@ -307,6 +344,10 @@ namespace osu.Game.Tests.Visual.Gameplay public bool PauseOverlayVisible => PauseOverlay.State.Value == Visibility.Visible; + public void ExitViaPause() => PerformExit(true); + + public void ExitViaQuickExit() => PerformExit(false); + public override void OnEntering(IScreen last) { base.OnEntering(last); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs index e43e5ba3ce..49c1163c6c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,21 +9,17 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps; +using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [HeadlessTest] // we alter unsafe properties on the game host to test inactive window state. public class TestScenePauseWhenInactive : OsuPlayerTestScene { - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) - { - var beatmap = (Beatmap)base.CreateBeatmap(ruleset); - - beatmap.HitObjects.RemoveAll(h => h.StartTime < 30000); - - return beatmap; - } - [Resolved] private GameHost host { get; set; } @@ -33,10 +30,57 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("resume player", () => Player.GameplayClockContainer.Start()); AddAssert("ensure not paused", () => !Player.GameplayClockContainer.IsPaused.Value); + + AddStep("progress time to gameplay", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.GameplayStartTime)); + AddUntilStep("wait for pause", () => Player.GameplayClockContainer.IsPaused.Value); + } + + /// + /// Tests that if a pause from focus lose is performed while in pause cooldown, + /// the player will still pause after the cooldown is finished. + /// + [Test] + public void TestPauseWhileInCooldown() + { + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + AddStep("resume player", () => Player.GameplayClockContainer.Start()); + AddStep("skip to gameplay", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.GameplayStartTime)); + + AddStep("set inactive", () => ((Bindable)host.IsActive).Value = false); + AddUntilStep("wait for pause", () => Player.GameplayClockContainer.IsPaused.Value); + + AddStep("set active", () => ((Bindable)host.IsActive).Value = true); + + AddStep("resume player", () => Player.Resume()); + AddAssert("unpaused", () => !Player.GameplayClockContainer.IsPaused.Value); + + bool pauseCooldownActive = false; + + AddStep("set inactive again", () => + { + pauseCooldownActive = Player.PauseCooldownActive; + ((Bindable)host.IsActive).Value = false; + }); + AddAssert("pause cooldown active", () => pauseCooldownActive); AddUntilStep("wait for pause", () => Player.GameplayClockContainer.IsPaused.Value); - AddAssert("time of pause is after gameplay start time", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= Player.DrawableRuleset.GameplayStartTime); } protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, true, true); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + return new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 30000 }, + new HitCircle { StartTime = 35000 }, + }, + }; + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new TestWorkingBeatmap(beatmap, storyboard, Audio); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs index 3a71d4ca54..f94e122b30 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var beatmap = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty()); - return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod()?.CreateReplayScore(beatmap)); + return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod()?.CreateReplayScore(beatmap, Array.Empty())); } protected override void AddCheckSteps() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index bed48f3d86..44142b69d7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -298,7 +298,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); } @@ -309,7 +309,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); } @@ -321,7 +321,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index fc0cda2c1f..d688e9cb21 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -43,70 +43,60 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestStoppedSoundDoesntResumeAfterPause() { - DrawableSample sample = null; AddStep("start sample with looping", () => { - sample = skinnableSound.ChildrenOfType().First(); - skinnableSound.Looping = true; skinnableSound.Play(); }); - AddUntilStep("wait for sample to start playing", () => sample.Playing); + AddUntilStep("wait for sample to start playing", () => skinnableSound.IsPlaying); AddStep("stop sample", () => skinnableSound.Stop()); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddUntilStep("wait for sample to stop playing", () => !skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddWaitStep("wait a bit", 5); - AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); } [Test] public void TestLoopingSoundResumesAfterPause() { - DrawableSample sample = null; AddStep("start sample with looping", () => { skinnableSound.Looping = true; skinnableSound.Play(); - sample = skinnableSound.ChildrenOfType().First(); }); - AddUntilStep("wait for sample to start playing", () => sample.Playing); + AddUntilStep("wait for sample to start playing", () => skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddUntilStep("wait for sample to stop playing", () => !skinnableSound.IsPlaying); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); - AddUntilStep("wait for sample to start playing", () => sample.Playing); + AddUntilStep("wait for sample to start playing", () => skinnableSound.IsPlaying); } [Test] public void TestNonLoopingStopsWithPause() { - DrawableSample sample = null; - AddStep("start sample", () => - { - skinnableSound.Play(); - sample = skinnableSound.ChildrenOfType().First(); - }); + AddStep("start sample", () => skinnableSound.Play()); - AddAssert("sample playing", () => sample.Playing); + AddAssert("sample playing", () => skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddUntilStep("sample not playing", () => !sample.Playing); + AddUntilStep("sample not playing", () => !skinnableSound.IsPlaying); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); - AddAssert("sample not playing", () => !sample.Playing); - AddAssert("sample not playing", () => !sample.Playing); - AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); } [Test] @@ -119,10 +109,10 @@ namespace osu.Game.Tests.Visual.Gameplay sample = skinnableSound.ChildrenOfType().Single(); }); - AddAssert("sample playing", () => sample.Playing); + AddAssert("sample playing", () => skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddUntilStep("wait for sample to stop playing", () => !skinnableSound.IsPlaying); AddStep("trigger skin change", () => skinSource.TriggerSourceChanged()); @@ -133,11 +123,11 @@ namespace osu.Game.Tests.Visual.Gameplay return sample != oldSample; }); - AddAssert("new sample stopped", () => !sample.Playing); + AddAssert("new sample stopped", () => !skinnableSound.IsPlaying); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddWaitStep("wait a bit", 5); - AddAssert("new sample not played", () => !sample.Playing); + AddAssert("new sample not played", () => !skinnableSound.IsPlaying); } [Cached(typeof(ISkinSource))] @@ -155,7 +145,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Drawable GetDrawableComponent(ISkinComponent component) => source?.GetDrawableComponent(component); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); - public SampleChannel GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); + public Sample GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); public void TriggerSourceChanged() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 26524f07da..4a0e1282c4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -244,11 +243,6 @@ namespace osu.Game.Tests.Visual.Gameplay { } - protected override Task Connect() - { - return Task.CompletedTask; - } - public void StartPlay(int beatmapId) { this.beatmapId = beatmapId; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs new file mode 100644 index 0000000000..a718a98aa6 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Storyboards; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneStoryboardSamplePlayback : PlayerTestScene + { + private Storyboard storyboard; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.SetValue(OsuSetting.ShowStoryboard, true); + + storyboard = new Storyboard(); + var backgroundLayer = storyboard.GetLayer("Background"); + backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: -7000, volume: 20)); + backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: -5000, volume: 20)); + backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: 0, volume: 20)); + } + + [Test] + public void TestStoryboardSamplesStopDuringPause() + { + checkForFirstSamplePlayback(); + + AddStep("player paused", () => Player.Pause()); + AddAssert("player is currently paused", () => Player.GameplayClockContainer.IsPaused.Value); + AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying)); + + AddStep("player resume", () => Player.Resume()); + AddUntilStep("any storyboard samples playing after resume", () => allStoryboardSamples.Any(sound => sound.IsPlaying)); + } + + [Test] + public void TestStoryboardSamplesStopOnSkip() + { + checkForFirstSamplePlayback(); + + AddStep("skip intro", () => InputManager.Key(osuTK.Input.Key.Space)); + AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying)); + + AddUntilStep("any storyboard samples playing after skip", () => allStoryboardSamples.Any(sound => sound.IsPlaying)); + } + + private void checkForFirstSamplePlayback() + { + AddUntilStep("storyboard loaded", () => Player.Beatmap.Value.StoryboardLoaded); + AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying)); + } + + private IEnumerable allStoryboardSamples => Player.ChildrenOfType(); + + protected override bool AllowFail => false; + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, false); + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard ?? this.storyboard, Clock, Audio); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 874c1694eb..960aad10c6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -11,8 +11,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -20,6 +20,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Beatmaps; +using osu.Game.Users; using osuTK; using osuTK.Input; @@ -241,7 +242,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } private void moveToItem(int index, Vector2? offset = null) - => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType>().ElementAt(index), offset)); + => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().ElementAt(index), offset)); private void moveToDragger(int index, Vector2? offset = null) => AddStep($"move mouse to dragger {index}", () => { @@ -252,7 +253,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () => { var item = playlist.ChildrenOfType>().ElementAt(index); - InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); + InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); }); private void assertHandleVisibility(int index, bool visible) @@ -260,7 +261,7 @@ namespace osu.Game.Tests.Visual.Multiplayer () => (playlist.ChildrenOfType.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible); private void assertDeleteButtonVisibility(int index, bool visible) - => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); + => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); private void createPlaylist(bool allowEdit, bool allowSelection) { @@ -278,7 +279,21 @@ namespace osu.Game.Tests.Visual.Multiplayer playlist.Items.Add(new PlaylistItem { ID = i, - Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Beatmap = + { + Value = i % 2 == 1 + ? new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo + : new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "Artist", + Author = new User { Username = "Creator name here" }, + Title = "Long title used to check background colour", + }, + BeatmapSet = new BeatmapSetInfo() + } + }, Ruleset = { Value = new OsuRuleset().RulesetInfo }, RequiredMods = { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs new file mode 100644 index 0000000000..26a0301d8a --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.OnlinePlay; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneFreeModSelectOverlay : MultiplayerTestScene + { + [SetUp] + public new void Setup() => Schedule(() => + { + Child = new FreeModSelectOverlay + { + State = { Value = Visibility.Visible } + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 279dcfa584..5682fd5c3c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -69,6 +69,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus); } + [Test] + public void TestClickDeselection() + { + AddRooms(1); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + AddStep("click away", () => InputManager.Click(MouseButton.Left)); + AddAssert("no selection", () => checkRoomSelected(null)); + } + private void press(Key down) { AddStep($"press {down}", () => InputManager.Key(down)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 2e39471dc0..78bc51e47b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -1,46 +1,41 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Online.Multiplayer; using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayer : MultiplayerTestScene + public class TestSceneMultiplayer : ScreenTestScene { + private TestMultiplayer multiplayerScreen; + public TestSceneMultiplayer() { - var multi = new TestMultiplayer(); + AddStep("show", () => + { + multiplayerScreen = new TestMultiplayer(); - AddStep("show", () => LoadScreen(multi)); - AddUntilStep("wait for loaded", () => multi.IsLoaded); - } + // Needs to be added at a higher level since the multiplayer screen becomes non-current. + Child = multiplayerScreen.Client; - [Test] - public void TestOneUserJoinedMultipleTimes() - { - var user = new User { Id = 33 }; + LoadScreen(multiplayerScreen); + }); - AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); - - AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); - } - - [Test] - public void TestOneUserLeftMultipleTimes() - { - var user = new User { Id = 44 }; - - AddStep("add user", () => Client.AddUser(user)); - AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); - - AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); - AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); + AddUntilStep("wait for loaded", () => multiplayerScreen.IsLoaded); } private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer { + [Cached(typeof(StatefulMultiplayerClient))] + public readonly TestMultiplayerClient Client; + + public TestMultiplayer() + { + Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager); + } + protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index d016accc25..1ee848b902 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,6 +13,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Database; using osu.Game.Online; +using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu.Scoring; @@ -51,6 +51,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUpSteps] public override void SetUpSteps() { + AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = lookupCache.GetUserAsync(1).Result); + AddStep("create leaderboard", () => { leaderboard?.Expire(); @@ -86,6 +88,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestScoreUpdates() { AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 100); + AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded); } [Test] @@ -163,8 +166,6 @@ namespace osu.Game.Tests.Visual.Multiplayer ((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, Array.Empty())); } } - - protected override Task Connect() => Task.CompletedTask; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 95c333e9f4..8cfe5d8af2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -7,17 +7,23 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Select; @@ -50,6 +56,7 @@ namespace osu.Game.Tests.Visual.Multiplayer beatmaps.Add(new BeatmapInfo { Ruleset = rulesets.GetRuleset(i % 4), + RulesetID = i % 4, // workaround for efcore 5 compatibility. OnlineBeatmapID = beatmapId, Length = length, BPM = bpm, @@ -137,8 +144,30 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("mods not changed", () => SelectedMods.Value.Single() is TaikoModDoubleTime); } + [TestCase(typeof(OsuModHidden), typeof(OsuModHidden))] // Same mod. + [TestCase(typeof(OsuModHidden), typeof(OsuModTraceable))] // Incompatible. + public void TestAllowedModDeselectedWhenRequired(Type allowedMod, Type requiredMod) + { + AddStep($"select {allowedMod.ReadableName()} as allowed", () => songSelect.FreeMods.Value = new[] { (Mod)Activator.CreateInstance(allowedMod) }); + AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) }); + + AddAssert("freemods empty", () => songSelect.FreeMods.Value.Count == 0); + assertHasFreeModButton(allowedMod, false); + assertHasFreeModButton(requiredMod, false); + } + + private void assertHasFreeModButton(Type type, bool hasButton = true) + { + AddAssert($"{type.ReadableName()} {(hasButton ? "displayed" : "not displayed")} in freemod overlay", + () => songSelect.ChildrenOfType().Single().ChildrenOfType().All(b => b.Mod.GetType() != type)); + } + private class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect { + public new Bindable> Mods => base.Mods; + + public new Bindable> FreeMods => base.FreeMods; + public new BeatmapCarousel Carousel => base.Carousel; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 2344ebea0e..8869718fd1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -3,12 +3,10 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; -using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Tests.Beatmaps; @@ -20,9 +18,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerMatchSubScreen screen; - [Cached] - private OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); - public TestSceneMultiplayerMatchSubScreen() : base(false) { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 968a869532..e713cff233 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -1,13 +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.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Users; using osuTK; @@ -17,16 +23,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerParticipantsList : MultiplayerTestScene { [SetUp] - public new void Setup() => Schedule(() => - { - Child = new ParticipantsList - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Size = new Vector2(380, 0.7f) - }; - }); + public new void Setup() => Schedule(createNewParticipantsList); [Test] public void TestAddUser() @@ -73,6 +70,50 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().User.User == secondUser); } + [Test] + public void TestGameStateHasPriorityOverDownloadState() + { + AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + checkProgressBarVisibility(true); + + AddStep("make user ready", () => Client.ChangeState(MultiplayerUserState.Results)); + checkProgressBarVisibility(false); + AddUntilStep("ready mark visible", () => this.ChildrenOfType().Single().IsPresent); + + AddStep("make user ready", () => Client.ChangeState(MultiplayerUserState.Idle)); + checkProgressBarVisibility(true); + } + + [Test] + public void TestCorrectInitialState() + { + AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + AddStep("recreate list", createNewParticipantsList); + checkProgressBarVisibility(true); + } + + [Test] + public void TestBeatmapDownloadingStates() + { + AddStep("set to no map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded())); + AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + + checkProgressBarVisibility(true); + + AddRepeatStep("increment progress", () => + { + var progress = this.ChildrenOfType().Single().User.BeatmapAvailability.DownloadProgress ?? 0; + Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(progress + RNG.NextSingle(0.1f))); + }, 25); + + AddAssert("progress bar increased", () => this.ChildrenOfType().Single().Current.Value > 0); + + AddStep("set to importing map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Importing())); + checkProgressBarVisibility(false); + + AddStep("set to available", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.LocallyAvailable())); + } + [Test] public void TestToggleReadyState() { @@ -115,13 +156,81 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = i, Username = $"User {i}", - CurrentModeRank = RNG.Next(1, 100000), + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }); Client.ChangeUserState(i, (MultiplayerUserState)RNG.Next(0, (int)MultiplayerUserState.Results + 1)); + + if (RNG.NextBool()) + { + var beatmapState = (DownloadState)RNG.Next(0, (int)DownloadState.LocallyAvailable + 1); + + switch (beatmapState) + { + case DownloadState.NotDownloaded: + Client.ChangeUserBeatmapAvailability(i, BeatmapAvailability.NotDownloaded()); + break; + + case DownloadState.Downloading: + Client.ChangeUserBeatmapAvailability(i, BeatmapAvailability.Downloading(RNG.NextSingle())); + break; + + case DownloadState.Importing: + Client.ChangeUserBeatmapAvailability(i, BeatmapAvailability.Importing()); + break; + } + } } }); } + + [Test] + public void TestUserWithMods() + { + AddStep("add user", () => + { + Client.AddUser(new User + { + Id = 0, + Username = "User 0", + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + + Client.ChangeUserMods(0, new Mod[] + { + new OsuModHardRock(), + new OsuModDifficultyAdjust { ApproachRate = { Value = 1 } } + }); + }); + + for (var i = MultiplayerUserState.Idle; i < MultiplayerUserState.Results; i++) + { + var state = i; + AddStep($"set state: {state}", () => Client.ChangeUserState(0, state)); + } + } + + private void createNewParticipantsList() + { + Child = new ParticipantsList { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, Size = new Vector2(380, 0.7f) }; + } + + private void checkProgressBarVisibility(bool visible) => + AddUntilStep($"progress bar {(visible ? "is" : "is not")}visible", () => + this.ChildrenOfType().Single().IsPresent == visible); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 878776bf51..b44e5b1e5b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -6,6 +6,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Testing; @@ -26,8 +27,11 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerReadyButton : MultiplayerTestScene { private MultiplayerReadyButton button; + private OnlinePlayBeatmapAvailablilityTracker beatmapTracker; private BeatmapSetInfo importedSet; + private readonly Bindable selectedItem = new Bindable(); + private BeatmapManager beatmaps; private RulesetStore rulesets; @@ -38,7 +42,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); - beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + + Add(beatmapTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = selectedItem } + }); + + Dependencies.Cache(beatmapTracker); } [SetUp] @@ -46,20 +57,20 @@ namespace osu.Game.Tests.Visual.Multiplayer { importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + selectedItem.Value = new PlaylistItem + { + Beatmap = { Value = Beatmap.Value.BeatmapInfo }, + Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset }, + }; - Child = button = new MultiplayerReadyButton + if (button != null) + Remove(button); + + Add(button = new MultiplayerReadyButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), - SelectedItem = - { - Value = new PlaylistItem - { - Beatmap = { Value = Beatmap.Value.BeatmapInfo }, - Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset } - } - }, OnReadyClick = async () => { readyClickOperation = OngoingOperationTracker.BeginOperation(); @@ -73,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer await Client.ToggleReady(); readyClickOperation.Dispose(); } - }; + }); }); [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs index 7a3845cbf3..91c15de69f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Multiplayer { @@ -21,15 +24,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room { Name = { Value = "1" } }); + roomManager.CreateRoom(createRoom(r => r.Name.Value = "1")); roomManager.PartRoom(); - roomManager.CreateRoom(new Room { Name = { Value = "2" } }); + roomManager.CreateRoom(createRoom(r => r.Name.Value = "2")); roomManager.PartRoom(); roomManager.ClearRooms(); }); }); - AddAssert("manager polled for rooms", () => roomManager.Rooms.Count == 2); + AddAssert("manager polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 2); AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); } @@ -40,16 +43,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); }); }); AddStep("disconnect", () => roomContainer.Client.Disconnect()); - AddAssert("rooms cleared", () => roomManager.Rooms.Count == 0); + AddAssert("rooms cleared", () => ((RoomManager)roomManager).Rooms.Count == 0); AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); } @@ -60,9 +63,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); }); }); @@ -70,7 +73,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("disconnect", () => roomContainer.Client.Disconnect()); AddStep("connect", () => roomContainer.Client.Connect()); - AddAssert("manager polled for rooms", () => roomManager.Rooms.Count == 2); + AddAssert("manager polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 2); AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); } @@ -81,12 +84,12 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.ClearRooms(); }); }); - AddAssert("manager not polled for rooms", () => roomManager.Rooms.Count == 0); + AddAssert("manager not polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 0); AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); } @@ -97,11 +100,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); }); }); - AddAssert("multiplayer room joined", () => roomContainer.Client.Room != null); + AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null); } [Test] @@ -111,7 +114,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); }); }); @@ -126,14 +129,29 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - var r = new Room(); + var r = createRoom(); roomManager.CreateRoom(r); roomManager.PartRoom(); roomManager.JoinRoom(r); }); }); - AddAssert("multiplayer room joined", () => roomContainer.Client.Room != null); + AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null); + } + + private Room createRoom(Action initFunc = null) + { + var room = new Room(); + + room.Name.Value = "test room"; + room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + }); + + initFunc?.Invoke(room); + return room; } private TestMultiplayerRoomManager createRoomManager() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs similarity index 95% rename from osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs rename to osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index e0fd7d9874..7d83ba569d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -19,11 +19,11 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Screens.Select; +using osu.Game.Screens.OnlinePlay.Playlists; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchSongSelect : RoomTestScene + public class TestScenePlaylistsSongSelect : RoomTestScene { [Resolved] private BeatmapManager beatmapManager { get; set; } @@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private RulesetStore rulesets; - private TestMatchSongSelect songSelect; + private TestPlaylistsSongSelect songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -87,9 +87,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); + SelectedMods.Value = Array.Empty(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestMatchSongSelect())); + AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect())); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); } @@ -176,7 +177,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("item has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)Room.Playlist.First().RequiredMods[0]).SpeedChange.Value)); } - private class TestMatchSongSelect : MatchSongSelect + private class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; } diff --git a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs index c5038068ec..bf5338d81a 100644 --- a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs +++ b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Platform; @@ -17,6 +16,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Menu; @@ -61,10 +61,6 @@ namespace osu.Game.Tests.Visual.Navigation RecycleLocalStorage(); - // see MouseSettings - var frameworkConfig = host.Dependencies.Get(); - frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity).Disabled = false; - CreateGame(); }); @@ -81,7 +77,7 @@ namespace osu.Game.Tests.Visual.Navigation // todo: this can be removed once we can run audio tracks without a device present // see https://github.com/ppy/osu/issues/1302 - Game.LocalConfig.Set(OsuSetting.IntroSequence, IntroSequence.Circles); + Game.LocalConfig.SetValue(OsuSetting.IntroSequence, IntroSequence.Circles); Add(Game); } @@ -115,6 +111,8 @@ namespace osu.Game.Tests.Visual.Navigation public new Bindable Ruleset => base.Ruleset; + public new Bindable> SelectedMods => base.SelectedMods; + // if we don't do this, when running under nUnit the version that gets populated is that of nUnit. public override string Version => "test game"; @@ -133,7 +131,7 @@ namespace osu.Game.Tests.Visual.Navigation base.LoadComplete(); API.Login("Rhythm Champion", "osu!"); - Dependencies.Get().Set(Static.MutedAudioNotificationShownOnce, true); + Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true); } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 8480e6eaaa..f2bb518b2e 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -11,7 +11,9 @@ using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Options; using osu.Game.Tests.Beatmaps.IO; @@ -41,6 +43,30 @@ namespace osu.Game.Tests.Visual.Navigation exitViaEscapeAndConfirm(); } + [Test] + public void TestRetryFromResults() + { + Player player = null; + ResultsScreen results = null; + + WorkingBeatmap beatmap() => Game.Beatmap.Value; + + PushAndConfirm(() => new TestSongSelect()); + + AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("set autoplay", () => Game.SelectedMods.Value = new[] { new OsuModAutoplay() }); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); + AddStep("seek to end", () => player.ChildrenOfType().First().Seek(beatmap().Track.Length)); + AddUntilStep("wait for pass", () => (results = Game.ScreenStack.CurrentScreen as ResultsScreen) != null && results.IsLoaded); + AddStep("attempt to retry", () => results.ChildrenOfType().First().Action()); + AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != player && Game.ScreenStack.CurrentScreen is Player); + } + [TestCase(true)] [TestCase(false)] public void TestSongContinuesAfterExitPlayer(bool withUserPause) @@ -188,6 +214,50 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Options overlay still visible", () => songSelect.BeatmapOptionsOverlay.State.Value == Visibility.Visible); } + [Test] + public void TestSettingsViaHotkeyFromMainMenu() + { + AddAssert("toolbar not displayed", () => Game.Toolbar.State.Value == Visibility.Hidden); + + AddStep("press settings hotkey", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.O); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("settings displayed", () => Game.Settings.State.Value == Visibility.Visible); + } + + [Test] + public void TestToolbarHiddenByUser() + { + AddStep("Enter menu", () => InputManager.Key(Key.Enter)); + + AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); + + AddStep("Hide toolbar", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.T); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + pushEscape(); + + AddStep("Enter menu", () => InputManager.Key(Key.Enter)); + + AddAssert("Toolbar is hidden", () => Game.Toolbar.State.Value == Visibility.Hidden); + + AddStep("Enter song select", () => + { + InputManager.Key(Key.Enter); + InputManager.Key(Key.Enter); + }); + + AddAssert("Toolbar is hidden", () => Game.Toolbar.State.Value == Visibility.Hidden); + } + private void pushEscape() => AddStep("Press escape", () => InputManager.Key(Key.Escape)); diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs index c0b77b580e..c1c968e862 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs @@ -15,8 +15,8 @@ namespace osu.Game.Tests.Visual.Navigation using (var config = new OsuConfigManager(LocalStorage)) { - config.Set(OsuSetting.Version, "2020.101.0"); - config.Set(OsuSetting.DisplayStarsMaximum, 10.0); + config.SetValue(OsuSetting.Version, "2020.101.0"); + config.SetValue(OsuSetting.DisplayStarsMaximum, 10.0); } } @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Navigation { AddAssert("config has migrated value", () => Precision.AlmostEquals(Game.LocalConfig.Get(OsuSetting.DisplayStarsMaximum), 10.1)); - AddStep("set value again", () => Game.LocalConfig.Set(OsuSetting.DisplayStarsMaximum, 10)); + AddStep("set value again", () => Game.LocalConfig.SetValue(OsuSetting.DisplayStarsMaximum, 10.0)); AddStep("force save config", () => Game.LocalConfig.Save()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 6cb1687d1f..1349264bf9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -1,32 +1,81 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Overlays; +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osu.Game.Rulesets; namespace osu.Game.Tests.Visual.Online { public class TestSceneBeatmapListingOverlay : OsuTestScene { - protected override bool UseOnlineAPI => true; + private readonly List setsForResponse = new List(); - private readonly BeatmapListingOverlay overlay; + private BeatmapListingOverlay overlay; - public TestSceneBeatmapListingOverlay() + [BackgroundDependencyLoader] + private void load() { - Add(overlay = new BeatmapListingOverlay()); + Child = overlay = new BeatmapListingOverlay { State = { Value = Visibility.Visible } }; + + ((DummyAPIAccess)API).HandleRequest = req => + { + if (req is SearchBeatmapSetsRequest searchBeatmapSetsRequest) + { + searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse + { + BeatmapSets = setsForResponse, + }); + } + }; } [Test] - public void TestShow() + public void TestNoBeatmapsPlaceholder() { - AddStep("Show", overlay.Show); + AddStep("fetch for 0 beatmaps", () => fetchFor()); + AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + + AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any()); + + AddStep("fetch for 0 beatmaps", () => fetchFor()); + AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + + // fetch once more to ensure nothing happens in displaying placeholder again when it already is present. + AddStep("fetch for 0 beatmaps again", () => fetchFor()); + AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); } - [Test] - public void TestHide() + private void fetchFor(params BeatmapSetInfo[] beatmaps) { - AddStep("Hide", overlay.Hide); + setsForResponse.Clear(); + setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b))); + + // trigger arbitrary change for fetching. + overlay.ChildrenOfType().Single().Query.TriggerChange(); + } + + private class TestAPIBeatmapSet : APIBeatmapSet + { + private readonly BeatmapSetInfo beatmapSet; + + public TestAPIBeatmapSet(BeatmapSetInfo beatmapSet) + { + this.beatmapSet = beatmapSet; + } + + public override BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) => beatmapSet; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index 9e69530a77..74f53ebdca 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -103,26 +103,26 @@ namespace osu.Game.Tests.Visual.Online private void testLinksGeneral() { addMessageWithChecks("test!"); - addMessageWithChecks("osu.ppy.sh!"); - addMessageWithChecks("https://osu.ppy.sh!", 1, expectedActions: LinkAction.External); + addMessageWithChecks("dev.ppy.sh!"); + addMessageWithChecks("https://dev.ppy.sh!", 1, expectedActions: LinkAction.External); addMessageWithChecks("00:12:345 (1,2) - Test?", 1, expectedActions: LinkAction.OpenEditorTimestamp); addMessageWithChecks("Wiki link for tasty [[Performance Points]]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("(osu forums)[https://osu.ppy.sh/forum] (old link format)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[https://osu.ppy.sh/home New site] (new link format)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[osu forums](https://osu.ppy.sh/forum) (new link format 2)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[https://osu.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("is now listening to [https://osu.ppy.sh/s/93523 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmapSet); - addMessageWithChecks("is now playing [https://osu.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); - addMessageWithChecks("Let's (try)[https://osu.ppy.sh/home] [https://osu.ppy.sh/b/252238 multiple links] https://osu.ppy.sh/home", 3, + addMessageWithChecks("(osu forums)[https://dev.ppy.sh/forum] (old link format)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[https://dev.ppy.sh/home New site] (new link format)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[osu forums](https://dev.ppy.sh/forum) (new link format 2)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[https://dev.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("is now listening to [https://dev.ppy.sh/s/93523 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmapSet); + addMessageWithChecks("is now playing [https://dev.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); + addMessageWithChecks("Let's (try)[https://dev.ppy.sh/home] [https://dev.ppy.sh/b/252238 multiple links] https://dev.ppy.sh/home", 3, expectedActions: new[] { LinkAction.External, LinkAction.OpenBeatmap, LinkAction.External }); - addMessageWithChecks("[https://osu.ppy.sh/home New link format with escaped [and \\[ paired] braces]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[Markdown link format with escaped [and \\[ paired] braces](https://osu.ppy.sh/home)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("(Old link format with escaped (and \\( paired) parentheses)[https://osu.ppy.sh/home] and [[also a rogue wiki link]]", 2, expectedActions: new[] { LinkAction.External, LinkAction.External }); + addMessageWithChecks("[https://dev.ppy.sh/home New link format with escaped [and \\[ paired] braces]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[Markdown link format with escaped [and \\[ paired] braces](https://dev.ppy.sh/home)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("(Old link format with escaped (and \\( paired) parentheses)[https://dev.ppy.sh/home] and [[also a rogue wiki link]]", 2, expectedActions: new[] { LinkAction.External, LinkAction.External }); // note that there's 0 links here (they get removed if a channel is not found) addMessageWithChecks("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present)."); addMessageWithChecks("I am important!", 0, false, true); addMessageWithChecks("feels important", 0, true, true); - addMessageWithChecks("likes to post this [https://osu.ppy.sh/home link].", 1, true, true, expectedActions: LinkAction.External); + addMessageWithChecks("likes to post this [https://dev.ppy.sh/home link].", 1, true, true, expectedActions: LinkAction.External); addMessageWithChecks("Join my multiplayer game osump://12346.", 1, expectedActions: LinkAction.JoinMultiplayerMatch); addMessageWithChecks("Join my [multiplayer game](osump://12346).", 1, expectedActions: LinkAction.JoinMultiplayerMatch); addMessageWithChecks("Join my [#english](osu://chan/#english).", 1, expectedActions: LinkAction.OpenChannel); @@ -136,9 +136,9 @@ namespace osu.Game.Tests.Visual.Online int echoCounter = 0; addEchoWithWait("sent!", "received!"); - addEchoWithWait("https://osu.ppy.sh/home", null, 500); - addEchoWithWait("[https://osu.ppy.sh/forum let's try multiple words too!]"); - addEchoWithWait("(long loading times! clickable while loading?)[https://osu.ppy.sh/home]", null, 5000); + addEchoWithWait("https://dev.ppy.sh/home", null, 500); + addEchoWithWait("[https://dev.ppy.sh/forum let's try multiple words too!]"); + addEchoWithWait("(long loading times! clickable while loading?)[https://dev.ppy.sh/home]", null, 5000); void addEchoWithWait(string text, string completeText = null, double delay = 250) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 63bda08c88..0c199bfb62 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Online ensureSoleilyRemoved(); createButtonWithBeatmap(createSoleily()); AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); - AddStep("import soleily", () => beatmaps.Import(TestResources.GetTestBeatmapForImport())); + AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport())); AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineBeatmapSetID == 241526)); createButtonWithBeatmap(createSoleily()); AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable); diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 9bece39ca0..e8d9ff72af 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Online Username = "flyte", Id = 3103765, IsOnline = true, - CurrentModeRank = 1111, + Statistics = new UserStatistics { GlobalRank = 1111 }, Country = new Country { FlagName = "JP" }, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }, @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.Online Username = "peppy", Id = 2, IsOnline = false, - CurrentModeRank = 2222, + Statistics = new UserStatistics { GlobalRank = 2222 }, Country = new Country { FlagName = "AU" }, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", IsSupporter = true, diff --git a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs index 8f20bcdcc1..dc468bb62d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online private class TestFullscreenOverlay : FullscreenOverlay { public TestFullscreenOverlay() - : base(OverlayColourScheme.Pink, null) + : base(OverlayColourScheme.Pink) { Children = new Drawable[] { @@ -52,6 +52,17 @@ namespace osu.Game.Tests.Visual.Online }, }; } + + protected override OverlayHeader CreateHeader() => new TestHeader(); + + internal class TestHeader : OverlayHeader + { + protected override OverlayTitle CreateTitle() => new TestTitle(); + + internal class TestTitle : OverlayTitle + { + } + } } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs new file mode 100644 index 0000000000..fe1701a554 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.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.Game.Overlays; +using NUnit.Framework; + +namespace osu.Game.Tests.Visual.Online +{ + [Description("uses online API")] + public class TestSceneOnlineBeatmapListingOverlay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + private readonly BeatmapListingOverlay overlay; + + public TestSceneOnlineBeatmapListingOverlay() + { + Add(overlay = new BeatmapListingOverlay()); + } + + [Test] + public void TestShow() + { + AddStep("Show", overlay.Show); + } + + [Test] + public void TestHide() + { + AddStep("Hide", overlay.Hide); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs index 3b31192259..5bf9e31309 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual.Online { graph.Statistics.Value = new UserStatistics { - Ranks = new UserStatistics.UserRanks { Global = 123456 }, + GlobalRank = 123456, PP = 12345, }; }); @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Visual.Online { graph.Statistics.Value = new UserStatistics { - Ranks = new UserStatistics.UserRanks { Global = 89000 }, + GlobalRank = 89000, PP = 12345, RankHistory = new User.RankHistoryData { @@ -92,7 +92,7 @@ namespace osu.Game.Tests.Visual.Online { graph.Statistics.Value = new UserStatistics { - Ranks = new UserStatistics.UserRanks { Global = 89000 }, + GlobalRank = 89000, PP = 12345, RankHistory = new User.RankHistoryData { @@ -105,7 +105,7 @@ namespace osu.Game.Tests.Visual.Online { graph.Statistics.Value = new UserStatistics { - Ranks = new UserStatistics.UserRanks { Global = 12000 }, + GlobalRank = 12000, PP = 12345, RankHistory = new User.RankHistoryData { @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Visual.Online { graph.Statistics.Value = new UserStatistics { - Ranks = new UserStatistics.UserRanks { Global = 12000 }, + GlobalRank = 12000, PP = 12345, RankHistory = new User.RankHistoryData { diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs index 626f545b91..aff510dd95 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Online Add(rankingsOverlay = new TestRankingsOverlay { Country = { BindTarget = countryBindable }, - Scope = { BindTarget = scope }, + Header = { Current = { BindTarget = scope } }, }); } @@ -65,8 +65,6 @@ namespace osu.Game.Tests.Visual.Online private class TestRankingsOverlay : RankingsOverlay { public new Bindable Country => base.Country; - - public new Bindable Scope => base.Scope; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 492abdd88d..01e67b1681 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -8,16 +8,16 @@ using osu.Game.Users; using osuTK; using System; using System.Linq; +using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Overlays.Chat; +using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneStandAloneChatDisplay : OsuTestScene + public class TestSceneStandAloneChatDisplay : OsuManualInputManagerTestScene { - private readonly Channel testChannel = new Channel(); - private readonly User admin = new User { Username = "HappyStick", @@ -46,92 +46,97 @@ namespace osu.Game.Tests.Visual.Online [Cached] private ChannelManager channelManager = new ChannelManager(); - private readonly TestStandAloneChatDisplay chatDisplay; - private readonly TestStandAloneChatDisplay chatDisplay2; + private TestStandAloneChatDisplay chatDisplay; + private int messageIdSequence; + + private Channel testChannel; public TestSceneStandAloneChatDisplay() { Add(channelManager); - - Add(chatDisplay = new TestStandAloneChatDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding(20), - Size = new Vector2(400, 80) - }); - - Add(chatDisplay2 = new TestStandAloneChatDisplay(true) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding(20), - Size = new Vector2(400, 150) - }); } - protected override void LoadComplete() + [SetUp] + public void SetUp() => Schedule(() => { - base.LoadComplete(); + messageIdSequence = 0; + channelManager.CurrentChannel.Value = testChannel = new Channel(); - channelManager.CurrentChannel.Value = testChannel; + Children = new[] + { + chatDisplay = new TestStandAloneChatDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding(20), + Size = new Vector2(400, 80), + Channel = { Value = testChannel }, + }, + new TestStandAloneChatDisplay(true) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding(20), + Size = new Vector2(400, 150), + Channel = { Value = testChannel }, + } + }; + }); - chatDisplay.Channel.Value = testChannel; - chatDisplay2.Channel.Value = testChannel; - - int sequence = 0; - - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(sequence++) + [Test] + public void TestManyMessages() + { + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = admin, Content = "I am a wang!" })); - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = redUser, Content = "I am team red." })); - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = redUser, Content = "I plan to win!" })); - AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = blueUser, Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." })); - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = admin, Content = "Okay okay, calm down guys. Let's do this!" })); - AddStep("message from long username", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = longUsernameUser, Content = "Hi guys, my new username is lit!" })); - AddStep("message with new date", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = longUsernameUser, Content = "Message from the future!", Timestamp = DateTimeOffset.Now })); - AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom); + checkScrolledToBottom(); const int messages_per_call = 10; AddRepeatStep("add many messages", () => { for (int i = 0; i < messages_per_call; i++) { - testChannel.AddNewMessages(new Message(sequence++) + testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = longUsernameUser, Content = "Many messages! " + Guid.NewGuid(), @@ -153,9 +158,133 @@ namespace osu.Game.Tests.Visual.Online return true; }); - AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom); + checkScrolledToBottom(); } + /// + /// Tests that when a message gets wrapped by the chat display getting contracted while scrolled to bottom, the chat will still keep scrolling down. + /// + [Test] + public void TestMessageWrappingKeepsAutoScrolling() + { + fillChat(); + + // send message with short words for text wrapping to occur when contracting chat. + sendMessage(); + + AddStep("contract chat", () => chatDisplay.Width -= 100); + checkScrolledToBottom(); + + AddStep("send another message", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = admin, + Content = "As we were saying...", + })); + + checkScrolledToBottom(); + } + + [Test] + public void TestUserScrollOverride() + { + fillChat(); + + sendMessage(); + checkScrolledToBottom(); + + AddStep("User scroll up", () => + { + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre + new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + checkNotScrolledToBottom(); + sendMessage(); + checkNotScrolledToBottom(); + + AddRepeatStep("User scroll to bottom", () => + { + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre - new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height)); + InputManager.ReleaseButton(MouseButton.Left); + }, 5); + + checkScrolledToBottom(); + sendMessage(); + checkScrolledToBottom(); + } + + [Test] + public void TestLocalEchoMessageResetsScroll() + { + fillChat(); + + sendMessage(); + checkScrolledToBottom(); + + AddStep("User scroll up", () => + { + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre + new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + checkNotScrolledToBottom(); + sendMessage(); + checkNotScrolledToBottom(); + + sendLocalMessage(); + checkScrolledToBottom(); + + sendMessage(); + checkScrolledToBottom(); + } + + private void fillChat() + { + AddStep("fill chat", () => + { + for (int i = 0; i < 10; i++) + { + testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = $"some stuff {Guid.NewGuid()}", + }); + } + }); + + checkScrolledToBottom(); + } + + private void sendMessage() + { + AddStep("send lorem ipsum", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce et bibendum velit.", + })); + } + + private void sendLocalMessage() + { + AddStep("send local echo", () => testChannel.AddLocalEcho(new LocalEchoMessage + { + Sender = longUsernameUser, + Content = "This is a local echo message.", + })); + } + + private void checkScrolledToBottom() => + AddUntilStep("is scrolled to bottom", () => chatDisplay.ScrolledToBottom); + + private void checkNotScrolledToBottom() => + AddUntilStep("not scrolled to bottom", () => !chatDisplay.ScrolledToBottom); + private class TestStandAloneChatDisplay : StandAloneChatDisplay { public TestStandAloneChatDisplay(bool textbox = false) @@ -165,7 +294,7 @@ namespace osu.Game.Tests.Visual.Online protected DrawableChannel DrawableChannel => InternalChildren.OfType().First(); - protected OsuScrollContainer ScrollContainer => (OsuScrollContainer)((Container)DrawableChannel.Child).Child; + protected UserTrackingScrollContainer ScrollContainer => (UserTrackingScrollContainer)((Container)DrawableChannel.Child).Child; public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child; diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 7ade24f4de..03d079261d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -33,7 +33,8 @@ namespace osu.Game.Tests.Visual.Online ProfileOrder = new[] { "me" }, Statistics = new UserStatistics { - Ranks = new UserStatistics.UserRanks { Global = 2148, Country = 1 }, + GlobalRank = 2148, + CountryRank = 1, PP = 4567.89m, Level = new UserStatistics.LevelInfo { diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs index 9bb29541ec..e9e826e62f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs @@ -7,6 +7,8 @@ using osu.Game.Overlays.Comments; using osu.Game.Online.API.Requests.Responses; using osu.Framework.Allocation; using osu.Game.Overlays; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Containers; namespace osu.Game.Tests.Visual.Online { @@ -16,13 +18,33 @@ namespace osu.Game.Tests.Visual.Online [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - private VotePill votePill; + [Cached] + private LoginOverlay login; + + private TestPill votePill; + private readonly Container pillContainer; + + public TestSceneVotePill() + { + AddRange(new Drawable[] + { + pillContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both + }, + login = new LoginOverlay() + }); + } [Test] public void TestUserCommentPill() { + AddStep("Hide login overlay", () => login.Hide()); AddStep("Log in", logIn); AddStep("User comment", () => addVotePill(getUserComment())); + AddAssert("Background is transparent", () => votePill.Background.Alpha == 0); AddStep("Click", () => votePill.Click()); AddAssert("Not loading", () => !votePill.IsLoading); } @@ -30,8 +52,10 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestRandomCommentPill() { + AddStep("Hide login overlay", () => login.Hide()); AddStep("Log in", logIn); AddStep("Random comment", () => addVotePill(getRandomComment())); + AddAssert("Background is visible", () => votePill.Background.Alpha == 1); AddStep("Click", () => votePill.Click()); AddAssert("Loading", () => votePill.IsLoading); } @@ -39,10 +63,11 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestOfflineRandomCommentPill() { + AddStep("Hide login overlay", () => login.Hide()); AddStep("Log out", API.Logout); AddStep("Random comment", () => addVotePill(getRandomComment())); AddStep("Click", () => votePill.Click()); - AddAssert("Not loading", () => !votePill.IsLoading); + AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible); } private void logIn() => API.Login("localUser", "password"); @@ -63,12 +88,22 @@ namespace osu.Game.Tests.Visual.Online private void addVotePill(Comment comment) { - Clear(); - Add(votePill = new VotePill(comment) + pillContainer.Clear(); + pillContainer.Child = votePill = new TestPill(comment) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - }); + }; + } + + private class TestPill : VotePill + { + public new Box Background => base.Background; + + public TestPill(Comment comment) + : base(comment) + { + } } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 008c862cc3..618447eae2 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -4,7 +4,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics.Containers; @@ -28,12 +27,7 @@ namespace osu.Game.Tests.Visual.Playlists { base.SetUpSteps(); - AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - })); + AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen())); AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index 8dd81e02e2..255f147ec9 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Playlists Room.RecentParticipants.Add(new User { Username = "peppy", - CurrentModeRank = 1234, + Statistics = new UserStatistics { GlobalRank = 1234 }, Id = 2 }); } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index cdcded8f61..be8032cde8 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net; -using System.Threading.Tasks; using JetBrains.Annotations; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -76,7 +75,7 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => { userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; - bindHandler(3000, userScore); + bindHandler(true, userScore); }); createResults(() => userScore); @@ -89,7 +88,7 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestShowNullUserScoreWithDelay() { - AddStep("bind delayed handler", () => bindHandler(3000)); + AddStep("bind delayed handler", () => bindHandler(true)); createResults(); waitForDisplay(); @@ -103,7 +102,7 @@ namespace osu.Game.Tests.Visual.Playlists createResults(); waitForDisplay(); - AddStep("bind delayed handler", () => bindHandler(3000)); + AddStep("bind delayed handler", () => bindHandler(true)); for (int i = 0; i < 2; i++) { @@ -134,7 +133,7 @@ namespace osu.Game.Tests.Visual.Playlists createResults(() => userScore); waitForDisplay(); - AddStep("bind delayed handler", () => bindHandler(3000)); + AddStep("bind delayed handler", () => bindHandler(true)); for (int i = 0; i < 2; i++) { @@ -169,70 +168,47 @@ namespace osu.Game.Tests.Visual.Playlists AddWaitStep("wait for display", 5); } - private void bindHandler(double delay = 0, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => + private void bindHandler(bool delayed = false, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => { requestComplete = false; - if (failRequests) - { - triggerFail(request, delay); - return; - } + double delay = delayed ? 3000 : 0; - switch (request) + Scheduler.AddDelayed(() => { - case ShowPlaylistUserScoreRequest s: - if (userScore == null) - triggerFail(s, delay); - else - triggerSuccess(s, createUserResponse(userScore), delay); - break; + if (failRequests) + { + triggerFail(request); + return; + } - case IndexPlaylistScoresRequest i: - triggerSuccess(i, createIndexResponse(i), delay); - break; - } + switch (request) + { + case ShowPlaylistUserScoreRequest s: + if (userScore == null) + triggerFail(s); + else + triggerSuccess(s, createUserResponse(userScore)); + break; + + case IndexPlaylistScoresRequest i: + triggerSuccess(i, createIndexResponse(i)); + break; + } + }, delay); }; - private void triggerSuccess(APIRequest req, T result, double delay) + private void triggerSuccess(APIRequest req, T result) where T : class { - if (delay == 0) - success(); - else - { - Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMilliseconds(delay)); - Schedule(success); - }); - } - - void success() - { - requestComplete = true; - req.TriggerSuccess(result); - } + requestComplete = true; + req.TriggerSuccess(result); } - private void triggerFail(APIRequest req, double delay) + private void triggerFail(APIRequest req) { - if (delay == 0) - fail(); - else - { - Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMilliseconds(delay)); - Schedule(fail); - }); - } - - void fail() - { - requestComplete = true; - req.TriggerFailure(new WebException("Failed.")); - } + requestComplete = true; + req.TriggerFailure(new WebException("Failed.")); } private MultiplayerScore createUserResponse([NotNull] ScoreInfo userScore) diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 7be44a62de..591095252f 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap = createTestBeatmap(author) })); - AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Text == "mapper_name")); + AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Current.Value == "mapper_name")); } [Test] @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Ranking })); AddAssert("mapped by text not present", () => - this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text, "mapped", "by"))); + this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); } private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 8330b9b360..f495e0fb23 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -78,7 +78,7 @@ namespace osu.Game.Tests.Visual.Settings clickClearButton(); - AddAssert("first binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().First().Text.Text)); + AddAssert("first binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().First().Text.Text.ToString())); AddStep("click second binding", () => { @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Settings clickClearButton(); - AddAssert("second binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().ElementAt(1).Text.Text)); + AddAssert("second binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().ElementAt(1).Text.Text.ToString())); void clickClearButton() { diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs new file mode 100644 index 0000000000..8f1c17ed29 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.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.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Tests.Visual.Settings +{ + [TestFixture] + public class TestSceneSettingsItem : OsuTestScene + { + [Test] + public void TestRestoreDefaultValueButtonVisibility() + { + TestSettingsTextBox textBox = null; + + AddStep("create settings item", () => Child = textBox = new TestSettingsTextBox + { + Current = new Bindable + { + Default = "test", + Value = "test" + } + }); + AddAssert("restore button hidden", () => textBox.RestoreDefaultValueButton.Alpha == 0); + + AddStep("change value from default", () => textBox.Current.Value = "non-default"); + AddUntilStep("restore button shown", () => textBox.RestoreDefaultValueButton.Alpha > 0); + + AddStep("restore default", () => textBox.Current.SetDefault()); + AddUntilStep("restore button hidden", () => textBox.RestoreDefaultValueButton.Alpha == 0); + } + + private class TestSettingsTextBox : SettingsTextBox + { + public new Drawable RestoreDefaultValueButton => this.ChildrenOfType().Single(); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index 0b2c0ce63b..7ea6373763 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -7,6 +7,7 @@ using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; @@ -103,27 +104,27 @@ namespace osu.Game.Tests.Visual.SongSelect private void testBeatmapLabels(Ruleset ruleset) { - AddAssert("check version", () => infoWedge.Info.VersionLabel.Text == $"{ruleset.ShortName}Version"); - AddAssert("check title", () => infoWedge.Info.TitleLabel.Text == $"{ruleset.ShortName}Source — {ruleset.ShortName}Title"); - AddAssert("check artist", () => infoWedge.Info.ArtistLabel.Text == $"{ruleset.ShortName}Artist"); - AddAssert("check author", () => infoWedge.Info.MapperContainer.Children.OfType().Any(s => s.Text == $"{ruleset.ShortName}Author")); + AddAssert("check version", () => infoWedge.Info.VersionLabel.Current.Value == $"{ruleset.ShortName}Version"); + AddAssert("check title", () => infoWedge.Info.TitleLabel.Current.Value == $"{ruleset.ShortName}Source — {ruleset.ShortName}Title"); + AddAssert("check artist", () => infoWedge.Info.ArtistLabel.Current.Value == $"{ruleset.ShortName}Artist"); + AddAssert("check author", () => infoWedge.Info.MapperContainer.Children.OfType().Any(s => s.Current.Value == $"{ruleset.ShortName}Author")); } private void testInfoLabels(int expectedCount) { - AddAssert("check info labels exists", () => infoWedge.Info.InfoLabelContainer.Children.Any()); - AddAssert("check info labels count", () => infoWedge.Info.InfoLabelContainer.Children.Count == expectedCount); + AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType().Any()); + AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType().Count() == expectedCount); } [Test] public void TestNullBeatmap() { selectBeatmap(null); - AddAssert("check empty version", () => string.IsNullOrEmpty(infoWedge.Info.VersionLabel.Text)); - AddAssert("check default title", () => infoWedge.Info.TitleLabel.Text == Beatmap.Default.BeatmapInfo.Metadata.Title); - AddAssert("check default artist", () => infoWedge.Info.ArtistLabel.Text == Beatmap.Default.BeatmapInfo.Metadata.Artist); + AddAssert("check empty version", () => string.IsNullOrEmpty(infoWedge.Info.VersionLabel.Current.Value)); + AddAssert("check default title", () => infoWedge.Info.TitleLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Title); + AddAssert("check default artist", () => infoWedge.Info.ArtistLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Artist); AddAssert("check empty author", () => !infoWedge.Info.MapperContainer.Children.Any()); - AddAssert("check no info labels", () => !infoWedge.Info.InfoLabelContainer.Children.Any()); + AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any()); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 53a956c77c..9b8b74e6f6 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -186,6 +186,7 @@ namespace osu.Game.Tests.Visual.SongSelect Metadata = metadata, BaseDifficulty = new BeatmapDifficulty(), Ruleset = ruleset, + RulesetID = ruleset.ID.GetValueOrDefault(), // workaround for efcore 5 compatibility. StarDifficulty = difficultyIndex + 1, Version = $"SR{difficultyIndex + 1}" }).ToList() diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index 5d0fb248df..c13bdf0955 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.SongSelect Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); - beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); base.Content.AddRange(new Drawable[] { diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 35c6d62cb7..057b539e44 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -207,14 +207,14 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); addRulesetImportStep(0); - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, false)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); AddStep("return", () => songSelect.MakeCurrent()); AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); @@ -297,13 +297,13 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); - AddStep(@"Sort by Artist", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Artist)); - AddStep(@"Sort by Title", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Title)); - AddStep(@"Sort by Author", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Author)); - AddStep(@"Sort by DateAdded", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); - AddStep(@"Sort by BPM", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.BPM)); - AddStep(@"Sort by Length", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Length)); - AddStep(@"Sort by Difficulty", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); + AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); + AddStep(@"Sort by Author", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Author)); + AddStep(@"Sort by DateAdded", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); + AddStep(@"Sort by BPM", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.BPM)); + AddStep(@"Sort by Length", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Length)); + AddStep(@"Sort by Difficulty", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); } [Test] @@ -470,7 +470,7 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(0); // used for filter check below - AddStep("allow convert display", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); @@ -648,7 +648,7 @@ namespace osu.Game.Tests.Visual.SongSelect { int changeCount = 0; - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, false)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); AddStep("bind beatmap changed", () => { Beatmap.ValueChanged += onChange; @@ -686,7 +686,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("selection changed only fired twice", () => changeCount == 2); AddStep("unbind beatmap changed", () => Beatmap.ValueChanged -= onChange); - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); // ReSharper disable once AccessToModifiedClosure void onChange(ValueChangedEvent valueChangedEvent) => changeCount++; @@ -911,9 +911,11 @@ namespace osu.Game.Tests.Visual.SongSelect int length = RNG.Next(30000, 200000); double bpm = RNG.NextSingle(80, 200); + var ruleset = getRuleset(); beatmaps.Add(new BeatmapInfo { - Ruleset = getRuleset(), + Ruleset = ruleset, + RulesetID = ruleset.ID.GetValueOrDefault(), // workaround for efcore 5 compatibility. OnlineBeatmapID = beatmapId, Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", Length = length, diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index a9747e73f9..9602758ffc 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -92,10 +92,10 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestExplicitConfig() { - AddStep("configure explicit content to allowed", () => localConfig.Set(OsuSetting.ShowOnlineExplicitContent, true)); + AddStep("configure explicit content to allowed", () => localConfig.SetValue(OsuSetting.ShowOnlineExplicitContent, true)); AddAssert("explicit control set to show", () => control.ExplicitContent.Value == SearchExplicit.Show); - AddStep("configure explicit content to disallowed", () => localConfig.Set(OsuSetting.ShowOnlineExplicitContent, false)); + AddStep("configure explicit content to disallowed", () => localConfig.SetValue(OsuSetting.ShowOnlineExplicitContent, false)); AddAssert("explicit control set to hide", () => control.ExplicitContent.Value == SearchExplicit.Hide); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 81862448a8..97f3b2954d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.UserInterface dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, dependencies.Get(), dependencies.Get(), Beatmap.Default)); dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); - beatmap = beatmapManager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).Result.Beatmaps[0]; + beatmap = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).Result.Beatmaps[0]; for (int i = 0; i < 50; i++) { @@ -145,7 +145,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("click delete option", () => { - InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToLowerInvariant() == "delete")); + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToString().ToLowerInvariant() == "delete")); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 1e3b1c2ffd..546e905ded 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual.UserInterface var multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); var expectedValue = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; - return expectedValue == footerButtonMods.MultiplierText.Text; + return expectedValue == footerButtonMods.MultiplierText.Current.Value; } private class TestFooterButtonMods : FooterButtonMods diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs new file mode 100644 index 0000000000..e7fa7d9235 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModIcon : OsuTestScene + { + [Test] + public void TestChangeModType() + { + ModIcon icon = null; + + AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); + AddStep("change mod", () => icon.Mod = new OsuModEasy()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 0d0acbb8f4..2885dbee00 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -40,24 +41,8 @@ namespace osu.Game.Tests.Visual.UserInterface [SetUp] public void SetUp() => Schedule(() => { - Children = new Drawable[] - { - modSelect = new TestModSelectOverlay - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - SelectedMods = { BindTarget = SelectedMods } - }, - - modDisplay = new ModDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Position = new Vector2(-5, 25), - Current = { BindTarget = modSelect.SelectedMods } - } - }; + SelectedMods.Value = Array.Empty(); + createDisplay(() => new TestModSelectOverlay()); }); [SetUpSteps] @@ -66,6 +51,50 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("show", () => modSelect.Show()); } + [Test] + public void TestSettingsResetOnDeselection() + { + var osuModDoubleTime = new OsuModDoubleTime { SpeedChange = { Value = 1.2 } }; + + changeRuleset(0); + + AddStep("set dt mod with custom rate", () => { SelectedMods.Value = new[] { osuModDoubleTime }; }); + + AddAssert("selected mod matches", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.Value == 1.2); + + AddStep("deselect", () => modSelect.DeselectAllButton.Click()); + AddAssert("selected mods empty", () => SelectedMods.Value.Count == 0); + + AddStep("reselect", () => modSelect.GetModButton(osuModDoubleTime).Click()); + AddAssert("selected mod has default value", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.IsDefault == true); + } + + [Test] + public void TestAnimationFlushOnClose() + { + changeRuleset(0); + + AddStep("Select all fun mods", () => + { + modSelect.ModSectionsContainer + .Single(c => c.ModType == ModType.DifficultyIncrease) + .SelectAll(); + }); + + AddUntilStep("many mods selected", () => modDisplay.Current.Value.Count >= 5); + + AddStep("trigger deselect and close overlay", () => + { + modSelect.ModSectionsContainer + .Single(c => c.ModType == ModType.DifficultyIncrease) + .DeselectAll(); + + modSelect.Hide(); + }); + + AddAssert("all mods deselected", () => modDisplay.Current.Value.Count == 0); + } + [Test] public void TestOsuMods() { @@ -134,6 +163,8 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestExternallySetCustomizedMod() { + changeRuleset(0); + AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); AddAssert("ensure button is selected and customized accordingly", () => @@ -143,6 +174,85 @@ namespace osu.Game.Tests.Visual.UserInterface }); } + [Test] + public void TestSettingsAreRetainedOnReload() + { + changeRuleset(0); + + AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); + + AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); + + AddStep("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay())); + + AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); + } + + [Test] + public void TestExternallySetModIsReplacedByOverlayInstance() + { + Mod external = new OsuModDoubleTime(); + Mod overlayButtonMod = null; + + changeRuleset(0); + + AddStep("set mod externally", () => { SelectedMods.Value = new[] { external }; }); + + AddAssert("ensure button is selected", () => + { + var button = modSelect.GetModButton(SelectedMods.Value.Single()); + overlayButtonMod = button.SelectedMod; + return overlayButtonMod.GetType() == external.GetType(); + }); + + // Right now, when an external change occurs, the ModSelectOverlay will replace the global instance with its own + AddAssert("mod instance doesn't match", () => external != overlayButtonMod); + + AddAssert("one mod present in global selected", () => SelectedMods.Value.Count == 1); + AddAssert("globally selected matches button's mod instance", () => SelectedMods.Value.Contains(overlayButtonMod)); + AddAssert("globally selected doesn't contain original external change", () => !SelectedMods.Value.Contains(external)); + } + + [Test] + public void TestNonStacked() + { + changeRuleset(0); + + AddStep("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay())); + + AddStep("show", () => modSelect.Show()); + + AddAssert("ensure all buttons are spread out", () => modSelect.ChildrenOfType().All(m => m.Mods.Length <= 1)); + } + + [Test] + public void TestChangeIsValidChangesButtonVisibility() + { + changeRuleset(0); + + AddAssert("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); + + AddStep("make double time invalid", () => modSelect.IsValidMod = m => !(m is OsuModDoubleTime)); + AddUntilStep("double time not visible", () => modSelect.ChildrenOfType().All(b => !b.Mods.Any(m => m is OsuModDoubleTime))); + AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore))); + + AddStep("make double time valid again", () => modSelect.IsValidMod = m => true); + AddUntilStep("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); + AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore))); + } + + [Test] + public void TestChangeIsValidPreservesSelection() + { + changeRuleset(0); + + AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); + AddAssert("DT + HD selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); + + AddStep("make NF invalid", () => modSelect.IsValidMod = m => !(m is ModNoFail)); + AddAssert("DT + HD still selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); + } + private void testSingleMod(Mod mod) { selectNext(mod); @@ -262,12 +372,36 @@ namespace osu.Game.Tests.Visual.UserInterface private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour()); - private class TestModSelectOverlay : ModSelectOverlay + private void createDisplay(Func createOverlayFunc) + { + Children = new Drawable[] + { + modSelect = createOverlayFunc().With(d => + { + d.Origin = Anchor.BottomCentre; + d.Anchor = Anchor.BottomCentre; + d.SelectedMods.BindTarget = SelectedMods; + }), + modDisplay = new ModDisplay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Position = new Vector2(-5, 25), + Current = { BindTarget = modSelect.SelectedMods } + } + }; + } + + private class TestModSelectOverlay : LocalPlayerModSelectOverlay { public new Bindable> SelectedMods => base.SelectedMods; public bool AllLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); + public new FillFlowContainer ModSectionsContainer => + base.ModSectionsContainer; + public ModButton GetModButton(Mod mod) { var section = ModSectionsContainer.Children.Single(s => s.ModType == mod.Type); @@ -280,5 +414,10 @@ namespace osu.Game.Tests.Visual.UserInterface public new Color4 LowMultiplierColour => base.LowMultiplierColour; public new Color4 HighMultiplierColour => base.HighMultiplierColour; } + + private class TestNonStackedModSelectOverlay : TestModSelectOverlay + { + protected override bool Stacked => false; + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index 8614700b15..89f9b7381b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded); } - private class TestModSelectOverlay : ModSelectOverlay + private class TestModSelectOverlay : LocalPlayerModSelectOverlay { public new VisibilityContainer ModSettingsContainer => base.ModSettingsContainer; public new TriangleButton CustomiseButton => base.CustomiseButton; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index 43ba23e6c6..d0f6f3fe47 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -105,6 +105,15 @@ namespace osu.Game.Tests.Visual.UserInterface checkDisplayedCount(3); } + [Test] + public void TestError() + { + setState(Visibility.Visible); + AddStep(@"error #1", sendErrorNotification); + AddAssert("Is visible", () => notificationOverlay.State.Value == Visibility.Visible); + checkDisplayedCount(1); + } + [Test] public void TestSpam() { @@ -179,7 +188,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void sendBarrage() { - switch (RNG.Next(0, 4)) + switch (RNG.Next(0, 5)) { case 0: sendHelloNotification(); @@ -196,6 +205,10 @@ namespace osu.Game.Tests.Visual.UserInterface case 3: sendDownloadProgress(); break; + + case 4: + sendErrorNotification(); + break; } } @@ -214,6 +227,11 @@ namespace osu.Game.Tests.Visual.UserInterface notificationOverlay.Post(new BackgroundNotification { Text = @"Welcome to osu!. Enjoy your stay!" }); } + private void sendErrorNotification() + { + notificationOverlay.Post(new SimpleErrorNotification { Text = @"Rut roh!. Something went wrong!" }); + } + private void sendManyNotifications() { for (int i = 0; i < 10; i++) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index 45720548c8..493e2f54e5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -44,22 +44,22 @@ namespace osu.Game.Tests.Visual.UserInterface protected override void InitialiseDefaults() { - Set(TestConfigSetting.ToggleSettingNoKeybind, false); - Set(TestConfigSetting.EnumSettingNoKeybind, EnumSetting.Setting1); - Set(TestConfigSetting.ToggleSettingWithKeybind, false); - Set(TestConfigSetting.EnumSettingWithKeybind, EnumSetting.Setting1); + SetDefault(TestConfigSetting.ToggleSettingNoKeybind, false); + SetDefault(TestConfigSetting.EnumSettingNoKeybind, EnumSetting.Setting1); + SetDefault(TestConfigSetting.ToggleSettingWithKeybind, false); + SetDefault(TestConfigSetting.EnumSettingWithKeybind, EnumSetting.Setting1); base.InitialiseDefaults(); } - public void ToggleSetting(TestConfigSetting setting) => Set(setting, !Get(setting)); + public void ToggleSetting(TestConfigSetting setting) => SetValue(setting, !Get(setting)); public void IncrementEnumSetting(TestConfigSetting setting) { var nextValue = Get(setting) + 1; if (nextValue > EnumSetting.Setting4) nextValue = EnumSetting.Setting1; - Set(setting, nextValue); + SetValue(setting, nextValue); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs new file mode 100644 index 0000000000..5c2e6e457d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneSectionsContainer : OsuManualInputManagerTestScene + { + private readonly SectionsContainer container; + private float custom; + private const float header_height = 100; + + public TestSceneSectionsContainer() + { + container = new SectionsContainer + { + RelativeSizeAxes = Axes.Y, + Width = 300, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + FixedHeader = new Box + { + Alpha = 0.5f, + Width = 300, + Height = header_height, + Colour = Color4.Red + } + }; + container.SelectedSection.ValueChanged += section => + { + if (section.OldValue != null) + section.OldValue.Selected = false; + if (section.NewValue != null) + section.NewValue.Selected = true; + }; + Add(container); + } + + [Test] + public void TestSelection() + { + AddStep("clear", () => container.Clear()); + AddStep("add 1/8th", () => append(1 / 8.0f)); + AddStep("add third", () => append(1 / 3.0f)); + AddStep("add half", () => append(1 / 2.0f)); + AddStep("add full", () => append(1)); + AddSliderStep("set custom", 0.1f, 1.1f, 0.5f, i => custom = i); + AddStep("add custom", () => append(custom)); + AddStep("scroll to previous", () => container.ScrollTo( + container.Children.Reverse().SkipWhile(s => s != container.SelectedSection.Value).Skip(1).FirstOrDefault() ?? container.Children.First() + )); + AddStep("scroll to next", () => container.ScrollTo( + container.Children.SkipWhile(s => s != container.SelectedSection.Value).Skip(1).FirstOrDefault() ?? container.Children.Last() + )); + AddStep("scroll up", () => triggerUserScroll(1)); + AddStep("scroll down", () => triggerUserScroll(-1)); + } + + [Test] + public void TestCorrectSectionSelected() + { + const int sections_count = 11; + float[] alternating = { 0.07f, 0.33f, 0.16f, 0.33f }; + AddStep("clear", () => container.Clear()); + AddStep("fill with sections", () => + { + for (int i = 0; i < sections_count; i++) + append(alternating[i % alternating.Length]); + }); + + void step(int scrollIndex) + { + AddStep($"scroll to section {scrollIndex + 1}", () => container.ScrollTo(container.Children[scrollIndex])); + AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[scrollIndex]); + } + + for (int i = 1; i < sections_count; i++) + step(i); + for (int i = sections_count - 2; i >= 0; i--) + step(i); + + AddStep("scroll almost to end", () => container.ScrollTo(container.Children[sections_count - 2])); + AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[sections_count - 2]); + AddStep("scroll down", () => triggerUserScroll(-1)); + AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[sections_count - 1]); + } + + private static readonly ColourInfo selected_colour = ColourInfo.GradientVertical(Color4.Yellow, Color4.Gold); + private static readonly ColourInfo default_colour = ColourInfo.GradientVertical(Color4.White, Color4.DarkGray); + + private void append(float multiplier) + { + container.Add(new TestSection + { + Width = 300, + Height = (container.ChildSize.Y - header_height) * multiplier, + Colour = default_colour + }); + } + + private void triggerUserScroll(float direction) + { + InputManager.MoveMouseTo(container); + InputManager.ScrollVerticalBy(direction); + } + + private class TestSection : Box + { + public bool Selected + { + set => Colour = value ? selected_colour : default_colour; + } + } + } +} diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index c0c0578391..6f8e0fac6f 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,15 +3,19 @@ - - + + - + + WinExe net5.0 + + tests.ruleset + diff --git a/osu.Game.Tests/tests.ruleset b/osu.Game.Tests/tests.ruleset new file mode 100644 index 0000000000..a0abb781d3 --- /dev/null +++ b/osu.Game.Tests/tests.ruleset @@ -0,0 +1,6 @@ + + + + + + diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs new file mode 100644 index 0000000000..b4d9fa4222 --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs @@ -0,0 +1,60 @@ +// 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.Beatmaps; +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.Tournament.Components; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneTournamentModDisplay : TournamentTestScene + { + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + private FillFlowContainer fillFlow; + + private BeatmapInfo beatmap; + + [BackgroundDependencyLoader] + private void load() + { + var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = 490154 }); + req.Success += success; + api.Queue(req); + + Add(fillFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Full, + Spacing = new osuTK.Vector2(10) + }); + } + + private void success(APIBeatmap apiBeatmap) + { + beatmap = apiBeatmap.ToBeatmap(rulesets); + var mods = rulesets.GetRuleset(Ladder.Ruleset.Value.ID ?? 0).CreateInstance().GetAllMods(); + + foreach (var mod in mods) + { + fillFlow.Add(new TournamentBeatmapPanel(beatmap, mod.Acronym) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index 567d9f0d62..46c3b8bc3b 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -50,7 +50,7 @@ namespace osu.Game.Tournament.Tests.NonVisual storage.DeleteDirectory(string.Empty); using (var storageConfig = new TournamentStorageManager(storage)) - storageConfig.Set(StorageConfig.CurrentTournament, custom_tournament); + storageConfig.SetValue(StorageConfig.CurrentTournament, custom_tournament); try { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs new file mode 100644 index 0000000000..e2954c8f10 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Graphics.Cursor; +using osu.Game.Tournament.Screens.Drawings; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneDrawingsScreen : TournamentTestScene + { + [BackgroundDependencyLoader] + private void load(Storage storage) + { + using (var stream = storage.GetStream("drawings.txt", FileAccess.Write)) + using (var writer = new StreamWriter(stream)) + { + writer.WriteLine("KR : South Korea : KOR"); + writer.WriteLine("US : United States : USA"); + writer.WriteLine("PH : Philippines : PHL"); + writer.WriteLine("BR : Brazil : BRA"); + writer.WriteLine("JP : Japan : JPN"); + } + + Add(new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new DrawingsScreen() + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs index b240ef3ae5..0da8d1eb4a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Tournament.Components; @@ -16,5 +18,23 @@ namespace osu.Game.Tournament.Tests.Screens Add(new TourneyVideo("main") { RelativeSizeAxes = Axes.Both }); Add(new ScheduleScreen()); } + + [Test] + public void TestCurrentMatchTime() + { + setMatchDate(TimeSpan.FromDays(-1)); + setMatchDate(TimeSpan.FromSeconds(5)); + setMatchDate(TimeSpan.FromMinutes(4)); + setMatchDate(TimeSpan.FromHours(3)); + } + + private void setMatchDate(TimeSpan relativeTime) + // Humanizer cannot handle negative timespans. + => AddStep($"start time is {relativeTime}", () => + { + var match = CreateSampleMatch(); + match.Date.Value = DateTimeOffset.Now + relativeTime; + Ladder.CurrentMatch.Value = match; + }); } } diff --git a/osu.Game.Tournament.Tests/TournamentTestBrowser.cs b/osu.Game.Tournament.Tests/TournamentTestBrowser.cs index f7ad757926..50bdcd86c5 100644 --- a/osu.Game.Tournament.Tests/TournamentTestBrowser.cs +++ b/osu.Game.Tournament.Tests/TournamentTestBrowser.cs @@ -13,15 +13,18 @@ namespace osu.Game.Tournament.Tests { base.LoadComplete(); - LoadComponentAsync(new Background("Menu/menu-background-0") + BracketLoadTask.ContinueWith(_ => Schedule(() => { - Colour = OsuColour.Gray(0.5f), - Depth = 10 - }, AddInternal); + LoadComponentAsync(new Background("Menu/menu-background-0") + { + Colour = OsuColour.Gray(0.5f), + Depth = 10 + }, AddInternal); - // Have to construct this here, rather than in the constructor, because - // we depend on some dependencies to be loaded within OsuGameBase.load(). - Add(new TestBrowser()); + // Have to construct this here, rather than in the constructor, because + // we depend on some dependencies to be loaded within OsuGameBase.load(). + Add(new TestBrowser()); + })); } } } diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index d22da25f9d..cdfd19c157 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Platform; using osu.Framework.Testing; @@ -112,11 +113,11 @@ namespace osu.Game.Tournament.Tests }, Players = { - new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 12 } } }, - new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 16 } } }, - new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 20 } } }, - new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 24 } } }, - new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 30 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 12 } }, + new User { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 16 } }, + new User { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 20 } }, + new User { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 24 } }, + new User { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 30 } }, } } }, @@ -154,13 +155,22 @@ namespace osu.Game.Tournament.Tests protected override void LoadAsyncComplete() { - // this has to be run here rather than LoadComplete because - // TestScene.cs is checking the IsLoaded state (on another thread) and expects - // the runner to be loaded at that point. - Add(runner = new TestSceneTestRunner.TestRunner()); + BracketLoadTask.ContinueWith(_ => Schedule(() => + { + // this has to be run here rather than LoadComplete because + // TestScene.cs is checking the IsLoaded state (on another thread) and expects + // the runner to be loaded at that point. + Add(runner = new TestSceneTestRunner.TestRunner()); + })); } - public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test); + public void RunTestBlocking(TestScene test) + { + while (runner?.IsLoaded != true && Host.ExecutionState == ExecutionState.Running) + Thread.Sleep(10); + + runner?.RunTestBlocking(test); + } } } } diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 185b35e40d..b20583dd7e 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index 477bf4bd63..e6d73c6e83 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.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -23,7 +22,7 @@ namespace osu.Game.Tournament.Components public class TournamentBeatmapPanel : CompositeDrawable { public readonly BeatmapInfo Beatmap; - private readonly string mods; + private readonly string mod; private const float horizontal_padding = 10; private const float vertical_padding = 10; @@ -33,12 +32,12 @@ namespace osu.Game.Tournament.Components private readonly Bindable currentMatch = new Bindable(); private Box flash; - public TournamentBeatmapPanel(BeatmapInfo beatmap, string mods = null) + public TournamentBeatmapPanel(BeatmapInfo beatmap, string mod = null) { if (beatmap == null) throw new ArgumentNullException(nameof(beatmap)); Beatmap = beatmap; - this.mods = mods; + this.mod = mod; Width = 400; Height = HEIGHT; } @@ -75,9 +74,9 @@ namespace osu.Game.Tournament.Components { new TournamentSpriteText { - Text = new LocalisedString(( + Text = new RomanisableString( $"{Beatmap.Metadata.ArtistUnicode ?? Beatmap.Metadata.Artist} - {Beatmap.Metadata.TitleUnicode ?? Beatmap.Metadata.Title}", - $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}")), + $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}"), Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer @@ -122,23 +121,15 @@ namespace osu.Game.Tournament.Components }, }); - if (!string.IsNullOrEmpty(mods)) + if (!string.IsNullOrEmpty(mod)) { - AddInternal(new Container + AddInternal(new TournamentModIcon(mod) { - RelativeSizeAxes = Axes.Y, - Width = 60, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding(10), - Child = new Sprite - { - FillMode = FillMode.Fit, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Texture = textures.Get($"mods/{mods}"), - } + Width = 60, + RelativeSizeAxes = Axes.Y, }); } } diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs new file mode 100644 index 0000000000..43ac92d285 --- /dev/null +++ b/osu.Game.Tournament/Components/TournamentModIcon.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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets; +using osu.Game.Rulesets.UI; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Components +{ + /// + /// Mod icon displayed in tournament usages, allowing user overridden graphics. + /// + public class TournamentModIcon : CompositeDrawable + { + private readonly string modAcronym; + + [Resolved] + private RulesetStore rulesets { get; set; } + + public TournamentModIcon(string modAcronym) + { + this.modAcronym = modAcronym; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures, LadderInfo ladderInfo) + { + var customTexture = textures.Get($"mods/{modAcronym}"); + + if (customTexture != null) + { + AddInternal(new Sprite + { + FillMode = FillMode.Fit, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Texture = customTexture + }); + + return; + } + + var ruleset = rulesets.GetRuleset(ladderInfo.Ruleset.Value?.ID ?? 0); + var modIcon = ruleset?.CreateInstance().GetAllMods().FirstOrDefault(mod => mod.Acronym == modAcronym); + + if (modIcon == null) + return; + + AddInternal(new ModIcon(modIcon, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.5f) + }); + } + } +} diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs index 2ba1b6be8f..5d9fed6288 100644 --- a/osu.Game.Tournament/IO/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -70,7 +70,7 @@ namespace osu.Game.Tournament.IO moveFileIfExists("drawings.ini", destination); ChangeTargetStorage(newStorage); - storageConfig.Set(StorageConfig.CurrentTournament, default_tournament); + storageConfig.SetValue(StorageConfig.CurrentTournament, default_tournament); storageConfig.Save(); } diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index 7fca75cea4..7074ae413c 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -36,7 +36,7 @@ namespace osu.Game.Tournament.Models { get { - var ranks = Players.Select(p => p.Statistics?.Ranks.Global) + var ranks = Players.Select(p => p.Statistics?.GlobalRank) .Where(i => i.HasValue) .Select(i => i.Value) .ToArray(); diff --git a/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs b/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs index d197c0f5d9..1a2f5a1ff4 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs @@ -12,8 +12,8 @@ namespace osu.Game.Tournament.Screens.Drawings.Components protected override void InitialiseDefaults() { - Set(DrawingsConfig.Groups, 8, 1, 8); - Set(DrawingsConfig.TeamsPerGroup, 8, 1, 8); + SetDefault(DrawingsConfig.Groups, 8, 1, 8); + SetDefault(DrawingsConfig.TeamsPerGroup, 8, 1, 8); } public DrawingsConfigManager(Storage storage) diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs index 3ff4718b75..c7060bd538 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs @@ -345,7 +345,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Flag.Anchor = Anchor.Centre; Flag.Origin = Anchor.Centre; - Flag.Scale = new Vector2(0.9f); + Flag.Scale = new Vector2(0.7f); InternalChildren = new Drawable[] { diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index 88289ad6bd..c1d8c8ddd3 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -192,12 +192,7 @@ namespace osu.Game.Tournament.Screens.Schedule Origin = Anchor.CentreLeft, Children = new Drawable[] { - new TournamentSpriteText - { - Text = "Starting ", - Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) - }, - new DrawableDate(match.NewValue.Date.Value) + new ScheduleMatchDate(match.NewValue.Date.Value) { Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) } @@ -251,6 +246,18 @@ namespace osu.Game.Tournament.Screens.Schedule } } + public class ScheduleMatchDate : DrawableDate + { + public ScheduleMatchDate(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true) + : base(date, textSize, italic) + { + } + + protected override string Format() => Date < DateTimeOffset.Now + ? $"Started {base.Format()}" + : $"Starting {base.Format()}"; + } + public class ScheduleContainer : Container { protected override Container Content => content; diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 55fc80dba2..4f66d89b7f 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -250,7 +250,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro }; foreach (var p in team.Players) - fill.Add(new RowDisplay(p.Username, p.Statistics?.Ranks.Global?.ToString("\\##,0") ?? "-")); + fill.Add(new RowDisplay(p.Username, p.Statistics?.GlobalRank?.ToString("\\##,0") ?? "-")); } internal class RowDisplay : CompositeDrawable diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index bbe4a53d8f..fadb821bef 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Colour; using osu.Game.Graphics.Cursor; using osu.Game.Tournament.Models; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -32,25 +33,24 @@ namespace osu.Game.Tournament private Drawable heightWarning; private Bindable windowSize; private Bindable windowMode; + private LoadingSpinner loadingSpinner; [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) { windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); - windowSize.BindValueChanged(size => ScheduleAfterChildren(() => - { - var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; - - heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; - }), true); - windowMode = frameworkConfig.GetBindable(FrameworkSetting.WindowMode); - windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => - { - windowMode.Value = WindowMode.Windowed; - }), true); - AddRange(new[] + Add(loadingSpinner = new LoadingSpinner(true, true) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(40), + }); + + loadingSpinner.Show(); + + BracketLoadTask.ContinueWith(_ => LoadComponentsAsync(new[] { new Container { @@ -93,7 +93,24 @@ namespace osu.Game.Tournament RelativeSizeAxes = Axes.Both, Child = new TournamentSceneManager() } - }); + }, drawables => + { + loadingSpinner.Hide(); + loadingSpinner.Expire(); + + AddRange(drawables); + + windowSize.BindValueChanged(size => ScheduleAfterChildren(() => + { + var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; + heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; + }), true); + + windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => + { + windowMode.Value = WindowMode.Windowed; + }), true); + })); } } } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 97c950261b..2ee52c35aa 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; +using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics.Textures; @@ -29,6 +30,10 @@ namespace osu.Game.Tournament private DependencyContainer dependencies; private FileBasedIPC ipc; + protected Task BracketLoadTask => taskCompletionSource.Task; + + private readonly TaskCompletionSource taskCompletionSource = new TaskCompletionSource(); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { return dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -46,14 +51,9 @@ namespace osu.Game.Tournament Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); - readBracket(); - - ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); - dependencies.CacheAs(new StableInfo(storage)); - dependencies.CacheAs(ipc = new FileBasedIPC()); - Add(ipc); + Task.Run(readBracket); } private void readBracket() @@ -68,10 +68,6 @@ namespace osu.Game.Tournament ladder ??= new LadderInfo(); ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First(); - Ruleset.BindTo(ladder.Ruleset); - - dependencies.Cache(ladder); - bool addedInfo = false; // assign teams @@ -127,6 +123,19 @@ namespace osu.Game.Tournament if (addedInfo) SaveChanges(); + + ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); + + Schedule(() => + { + Ruleset.BindTo(ladder.Ruleset); + + dependencies.Cache(ladder); + dependencies.CacheAs(ipc = new FileBasedIPC()); + Add(ipc); + + taskCompletionSource.SetResult(true); + }); } /// @@ -141,9 +150,11 @@ namespace osu.Game.Tournament { foreach (var p in t.Players) { - if (string.IsNullOrEmpty(p.Username) || p.Statistics == null) + if (string.IsNullOrEmpty(p.Username) + || p.Statistics?.GlobalRank == null + || p.Statistics?.CountryRank == null) { - PopulateUser(p); + PopulateUser(p, immediate: true); addedInfo = true; } } @@ -202,12 +213,14 @@ namespace osu.Game.Tournament return addedInfo; } - public void PopulateUser(User user, Action success = null, Action failure = null) + public void PopulateUser(User user, Action success = null, Action failure = null, bool immediate = false) { var req = new GetUserRequest(user.Id, Ruleset.Value); req.Success += res => { + user.Id = res.Id; + user.Username = res.Username; user.Statistics = res.Statistics; user.Country = res.Country; @@ -222,7 +235,10 @@ namespace osu.Game.Tournament failure?.Invoke(); }; - API.Queue(req); + if (immediate) + API.Perform(req); + else + API.Queue(req); } protected override void LoadComplete() diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 8d02af6574..d88fd1e62b 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -27,6 +27,8 @@ namespace osu.Game.Audio protected TrackManagerPreviewTrack CurrentTrack; + private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(OsuGameBase.GLOBAL_TRACK_VOLUME_ADJUST); + [BackgroundDependencyLoader] private void load() { @@ -35,6 +37,7 @@ namespace osu.Game.Audio trackStore = new PreviewTrackStore(new OnlineStore()); audio.AddItem(trackStore); + trackStore.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); trackStore.AddAdjustment(AdjustableProperty.Volume, audio.VolumeTrack); } diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index be2006e67a..e5b6a4bc44 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.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.Beatmaps.Timing; using osu.Game.Rulesets.Objects; using System.Collections.Generic; @@ -48,17 +49,34 @@ namespace osu.Game.Beatmaps public virtual IEnumerable GetStatistics() => Enumerable.Empty(); + public double GetMostCommonBeatLength() + { + // The last playable time in the beatmap - the last timing point extends to this time. + // Note: This is more accurate and may present different results because osu-stable didn't have the ability to calculate slider durations in this context. + double lastTime = HitObjects.LastOrDefault()?.GetEndTime() ?? ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0; + + var mostCommon = + // Construct a set of (beatLength, duration) tuples for each individual timing point. + ControlPointInfo.TimingPoints.Select((t, i) => + { + if (t.Time > lastTime) + return (beatLength: t.BeatLength, 0); + + var nextTime = i == ControlPointInfo.TimingPoints.Count - 1 ? lastTime : ControlPointInfo.TimingPoints[i + 1].Time; + return (beatLength: t.BeatLength, duration: nextTime - t.Time); + }) + // Aggregate durations into a set of (beatLength, duration) tuples for each beat length + .GroupBy(t => Math.Round(t.beatLength * 1000) / 1000) + .Select(g => (beatLength: g.Key, duration: g.Sum(t => t.duration))) + // Get the most common one, or 0 as a suitable default + .OrderByDescending(i => i.duration).FirstOrDefault(); + + return mostCommon.beatLength; + } + IBeatmap IBeatmap.Clone() => Clone(); - public Beatmap Clone() - { - var clone = (Beatmap)MemberwiseClone(); - - clone.ControlPointInfo = ControlPointInfo.CreateCopy(); - // todo: deep clone other elements as required. - - return clone; - } + public Beatmap Clone() => (Beatmap)MemberwiseClone(); } public class Beatmap : Beatmap diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index cb0b3a8d09..b291edd19d 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -17,12 +17,12 @@ namespace osu.Game.Beatmaps public abstract class BeatmapConverter : IBeatmapConverter where T : HitObject { - private event Action> ObjectConverted; + private event Action> objectConverted; event Action> IBeatmapConverter.ObjectConverted { - add => ObjectConverted += value; - remove => ObjectConverted -= value; + add => objectConverted += value; + remove => objectConverted -= value; } public IBeatmap Beatmap { get; } @@ -92,10 +92,10 @@ namespace osu.Game.Beatmaps var converted = ConvertHitObject(obj, beatmap, cancellationToken); - if (ObjectConverted != null) + if (objectConverted != null) { converted = converted.ToList(); - ObjectConverted.Invoke(obj, converted); + objectConverted.Invoke(obj, converted); } foreach (var c in converted) diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 37d262abe5..53d82c385d 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -69,8 +69,8 @@ namespace osu.Game.Beatmaps /// /// The to get the difficulty of. /// An optional which stops updating the star difficulty for the given . - /// A bindable that is updated to contain the star difficulty when it becomes available. - public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state (but not during updates to ruleset and mods if a stale value is already propagated). + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) { var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); @@ -90,9 +90,9 @@ namespace osu.Game.Beatmaps /// The to get the difficulty with. If null, the 's ruleset is used. /// The s to get the difficulty with. If null, no mods will be assumed. /// An optional which stops updating the star difficulty for the given . - /// A bindable that is updated to contain the star difficulty when it becomes available. - public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, - CancellationToken cancellationToken = default) + /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state. + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, + CancellationToken cancellationToken = default) => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); /// @@ -313,7 +313,7 @@ namespace osu.Game.Beatmaps } } - private class BindableStarDifficulty : Bindable + private class BindableStarDifficulty : Bindable { public readonly BeatmapInfo Beatmap; public readonly CancellationToken CancellationToken; diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 42418e532b..115d1b33bb 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -20,6 +20,7 @@ using osu.Framework.IO.Stores; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; @@ -64,7 +65,9 @@ namespace osu.Game.Beatmaps protected override string[] HashableFileTypes => new[] { ".osu" }; - protected override string ImportFromStablePath => "Songs"; + protected override string ImportFromStablePath => "."; + + protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage(); private readonly RulesetStore rulesets; private readonly BeatmapStore beatmaps; @@ -110,8 +113,6 @@ namespace osu.Game.Beatmaps { var metadata = new BeatmapMetadata { - Artist = "artist", - Title = "title", Author = user, }; @@ -125,7 +126,6 @@ namespace osu.Game.Beatmaps BaseDifficulty = new BeatmapDifficulty(), Ruleset = ruleset, Metadata = metadata, - Version = "difficulty" } } }; @@ -153,7 +153,7 @@ namespace osu.Game.Beatmaps bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0); if (onlineLookupQueue != null) - await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken); + await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false); // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0)) @@ -171,6 +171,8 @@ namespace osu.Game.Beatmaps if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null)) throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}."); + beatmapSet.Requery(ContextFactory); + // check if a set already exists with the same online id, delete if it does. if (beatmapSet.OnlineBeatmapSetID != null) { @@ -309,6 +311,9 @@ namespace osu.Game.Beatmaps workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this)); + // best effort; may be higher than expected. + GlobalStatistics.Get(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count(); + return working; } } @@ -451,7 +456,7 @@ namespace osu.Game.Beatmaps // TODO: this should be done in a better place once we actually need to dynamically update it. beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0; beatmap.BeatmapInfo.Length = calculateLength(beatmap); - beatmap.BeatmapInfo.BPM = beatmap.ControlPointInfo.BPMMode; + beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength(); beatmapInfos.Add(beatmap.BeatmapInfo); } diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index e90ccbb805..7c4b344c9e 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -7,7 +7,6 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Dapper; using Microsoft.Data.Sqlite; using osu.Framework.Development; using osu.Framework.IO.Network; @@ -154,20 +153,31 @@ namespace osu.Game.Beatmaps { using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online"))) { - var found = db.QuerySingleOrDefault( - "SELECT * FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path", beatmap); + db.Open(); - if (found != null) + using (var cmd = db.CreateCommand()) { - var status = (BeatmapSetOnlineStatus)found.approved; + cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path"; - beatmap.Status = status; - beatmap.BeatmapSet.Status = status; - beatmap.BeatmapSet.OnlineBeatmapSetID = found.beatmapset_id; - beatmap.OnlineBeatmapID = found.beatmap_id; + cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash)); + cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value)); + cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path)); - LogForModel(set, $"Cached local retrieval for {beatmap}."); - return true; + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + { + var status = (BeatmapSetOnlineStatus)reader.GetByte(2); + + beatmap.Status = status; + beatmap.BeatmapSet.Status = status; + beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0); + beatmap.OnlineBeatmapID = reader.GetInt32(1); + + LogForModel(set, $"Cached local retrieval for {beatmap}."); + return true; + } + } } } } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 39b3c23ddd..367f612dc8 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -51,7 +51,12 @@ namespace osu.Game.Beatmaps [JsonProperty(@"tags")] public string Tags { get; set; } + /// + /// The time in milliseconds to begin playing the track for preview purposes. + /// If -1, the track should begin playing at 40% of its length. + /// public int PreviewTime { get; set; } + public string AudioFile { get; set; } public string BackgroundFile { get; set; } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index e8a91e4001..5cc60a5758 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -101,13 +101,6 @@ namespace osu.Game.Beatmaps.ControlPoints public double BPMMinimum => 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; - /// - /// Finds the mode BPM (most common BPM) represented by the control points. - /// - [JsonIgnore] - public double BPMMode => - 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; - /// /// Remove all s and return to a pristine state. /// diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 0bc5605051..73337ab6f5 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -19,7 +19,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly BindableDouble SpeedMultiplierBindable = new BindableDouble(1) { - Precision = 0.1, + Precision = 0.01, Default = 1, MinValue = 0.1, MaxValue = 10 diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 96e18f120a..c62b803d1a 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -151,7 +151,7 @@ namespace osu.Game.Beatmaps.Drawables this.mods = mods; } - private IBindable localStarDifficulty; + private IBindable localStarDifficulty; [BackgroundDependencyLoader] private void load() @@ -160,7 +160,11 @@ namespace osu.Game.Beatmaps.Drawables localStarDifficulty = ruleset != null ? difficultyCache.GetBindableDifficulty(beatmap, ruleset, mods, difficultyCancellation.Token) : difficultyCache.GetBindableDifficulty(beatmap, difficultyCancellation.Token); - localStarDifficulty.BindValueChanged(difficulty => StarDifficulty.Value = difficulty.NewValue); + localStarDifficulty.BindValueChanged(d => + { + if (d.NewValue is StarDifficulty diff) + StarDifficulty.Value = diff; + }); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index eb05cbaf85..3206f7b3ab 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -34,8 +34,8 @@ namespace osu.Game.Beatmaps.Drawables /// protected virtual double UnloadDelay => 10000; - protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) - => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); + protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => + new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay) { RelativeSizeAxes = Axes.Both }; protected override double TransformDuration => 400; diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 37ab489da5..40bc75e847 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using osu.Framework.Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; @@ -66,16 +67,14 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseLine(Beatmap beatmap, Section section, string line) { - var strippedLine = StripComments(line); - switch (section) { case Section.General: - handleGeneral(strippedLine); + handleGeneral(line); return; case Section.Editor: - handleEditor(strippedLine); + handleEditor(line); return; case Section.Metadata: @@ -83,19 +82,19 @@ namespace osu.Game.Beatmaps.Formats return; case Section.Difficulty: - handleDifficulty(strippedLine); + handleDifficulty(line); return; case Section.Events: - handleEvent(strippedLine); + handleEvent(line); return; case Section.TimingPoints: - handleTimingPoint(strippedLine); + handleTimingPoint(line); return; case Section.HitObjects: - handleHitObject(strippedLine); + handleHitObject(line); return; } @@ -348,8 +347,8 @@ namespace osu.Game.Beatmaps.Formats if (split.Length >= 8) { LegacyEffectFlags effectFlags = (LegacyEffectFlags)Parsing.ParseInt(split[7]); - kiaiMode = effectFlags.HasFlag(LegacyEffectFlags.Kiai); - omitFirstBarSignature = effectFlags.HasFlag(LegacyEffectFlags.OmitFirstBarLine); + kiaiMode = effectFlags.HasFlagFast(LegacyEffectFlags.Kiai); + omitFirstBarSignature = effectFlags.HasFlagFast(LegacyEffectFlags.OmitFirstBarLine); } string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index df940e8c8e..d06478b9de 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -471,9 +471,6 @@ namespace osu.Game.Beatmaps.Formats private string toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo) { - if (hitSampleInfo == null) - return "0"; - if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) return legacy.CustomSampleBank.ToString(CultureInfo.InvariantCulture); diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 2fb24c24e0..10a716963e 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -36,6 +36,8 @@ namespace osu.Game.Beatmaps.Formats if (ShouldSkipLine(line)) continue; + line = StripComments(line).TrimEnd(); + if (line.StartsWith('[') && line.EndsWith(']')) { if (!Enum.TryParse(line[1..^1], out section)) @@ -71,8 +73,6 @@ namespace osu.Game.Beatmaps.Formats protected virtual void ParseLine(T output, Section section, string line) { - line = StripComments(line); - switch (section) { case Section.Colours: diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 9a244c8bb2..6301c42deb 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -45,8 +45,6 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseLine(Storyboard storyboard, Section section, string line) { - line = StripComments(line); - switch (section) { case Section.General: @@ -139,7 +137,7 @@ namespace osu.Game.Beatmaps.Formats // this is random as hell but taken straight from osu-stable. frameDelay = Math.Round(0.015 * frameDelay) * 1.186 * (1000 / 60f); - var loopType = split.Length > 8 ? (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), split[8]) : AnimationLoopType.LoopForever; + var loopType = split.Length > 8 ? parseAnimationLoopType(split[8]) : AnimationLoopType.LoopForever; storyboardSprite = new StoryboardAnimation(path, origin, new Vector2(x, y), frameCount, frameDelay, loopType); storyboard.GetLayer(layer).Add(storyboardSprite); break; @@ -341,6 +339,12 @@ namespace osu.Game.Beatmaps.Formats } } + private AnimationLoopType parseAnimationLoopType(string value) + { + var parsed = (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), value); + return Enum.IsDefined(typeof(AnimationLoopType), parsed) ? parsed : AnimationLoopType.LoopForever; + } + private void handleVariables(string line) { var pair = SplitKeyVal(line, '='); diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 8f27e0b0e9..9847ea020a 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -24,7 +24,7 @@ namespace osu.Game.Beatmaps /// /// The control points in this beatmap. /// - ControlPointInfo ControlPointInfo { get; } + ControlPointInfo ControlPointInfo { get; set; } /// /// The breaks in this beatmap. @@ -47,6 +47,11 @@ namespace osu.Game.Beatmaps /// IEnumerable GetStatistics(); + /// + /// Finds the most common beat length represented by the control points in this beatmap. + /// + double GetMostCommonBeatLength(); + /// /// Creates a shallow-clone of this beatmap and returns it. /// diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 30382c444f..f7f276230f 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -12,7 +12,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; -using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -34,8 +33,6 @@ namespace osu.Game.Beatmaps protected AudioManager AudioManager { get; } - private static readonly GlobalStatistic total_count = GlobalStatistics.Get(nameof(Beatmaps), $"Total {nameof(WorkingBeatmap)}s"); - protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { AudioManager = audioManager; @@ -47,8 +44,6 @@ namespace osu.Game.Beatmaps waveform = new RecyclableLazy(GetWaveform); storyboard = new RecyclableLazy(GetStoryboard); skin = new RecyclableLazy(GetSkin); - - total_count.Value++; } protected virtual Track GetVirtualTrack(double emptyLength = 0) @@ -266,6 +261,26 @@ namespace osu.Game.Beatmaps [NotNull] public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); + /// + /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. + /// + 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. @@ -311,11 +326,6 @@ namespace osu.Game.Beatmaps protected virtual ISkin GetSkin() => new DefaultSkin(); private readonly RecyclableLazy skin; - ~WorkingBeatmap() - { - total_count.Value--; - } - public class RecyclableLazy { private Lazy lazy; diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index ec0e9d5a89..1eceb56e33 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -29,6 +30,14 @@ namespace osu.Game.Collections /// protected virtual bool ShowManageCollectionsItem => true; + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public new Bindable Current + { + get => current.Current; + set => current.Current = value; + } + private readonly IBindableList collections = new BindableList(); private readonly IBindableList beatmaps = new BindableList(); private readonly BindableList filters = new BindableList(); @@ -36,25 +45,28 @@ namespace osu.Game.Collections [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + public CollectionFilterDropdown() { ItemSource = filters; - } - - [BackgroundDependencyLoader(permitNulls: true)] - private void load([CanBeNull] CollectionManager collectionManager) - { - if (collectionManager != null) - collections.BindTo(collectionManager.Collections); - - collections.CollectionChanged += (_, __) => collectionsChanged(); - collectionsChanged(); + Current.Value = new AllBeatmapsCollectionFilterMenuItem(); } protected override void LoadComplete() { base.LoadComplete(); + if (collectionManager != null) + collections.BindTo(collectionManager.Collections); + + // Dropdown has logic which triggers a change on the bindable with every change to the contained items. + // This is not desirable here, as it leads to multiple filter operations running even though nothing has changed. + // An extra bindable is enough to subvert this behaviour. + base.Current = Current; + + collections.BindCollectionChanged((_, __) => collectionsChanged(), true); Current.BindValueChanged(filterChanged, true); } @@ -110,7 +122,7 @@ namespace osu.Game.Collections Current.TriggerChange(); } - protected override string GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; + protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader().With(d => { @@ -128,7 +140,7 @@ namespace osu.Game.Collections public readonly Bindable SelectedItem = new Bindable(); private readonly Bindable collectionName = new Bindable(); - protected override string Label + protected override LocalisableString Label { get => base.Label; set { } // See updateText(). diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index 4a489d2945..0617996872 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.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.Bindables; @@ -9,7 +10,7 @@ namespace osu.Game.Collections /// /// A filter. /// - public class CollectionFilterMenuItem + public class CollectionFilterMenuItem : IEquatable { /// /// The collection to filter beatmaps from. @@ -33,6 +34,23 @@ namespace osu.Game.Collections Collection = collection; CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable("All beatmaps"); } + + public bool Equals(CollectionFilterMenuItem other) + { + if (other == null) + return false; + + // collections may have the same name, so compare first on reference equality. + // this relies on the assumption that only one instance of the BeatmapCollection exists game-wide, managed by CollectionManager. + if (Collection != null) + return Collection == other.Collection; + + // fallback to name-based comparison. + // this is required for special dropdown items which don't have a collection (all beatmaps / manage collections items below). + return CollectionName.Value == other.CollectionName.Value; + } + + public override int GetHashCode() => CollectionName.Value.GetHashCode(); } public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 569ac749a4..9723409c79 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -124,7 +124,7 @@ namespace osu.Game.Collections return Task.Run(async () => { using (var stream = stable.GetStream(database_name)) - await Import(stream); + await Import(stream).ConfigureAwait(false); }); } @@ -138,36 +138,44 @@ namespace osu.Game.Collections PostNotification?.Invoke(notification); - var collection = readCollections(stream, notification); - bool importCompleted = false; + var collections = readCollections(stream, notification); + await importCollections(collections).ConfigureAwait(false); - Schedule(() => - { - importCollections(collection); - importCompleted = true; - }); - - while (!IsDisposed && !importCompleted) - await Task.Delay(10); - - notification.CompletionText = $"Imported {collection.Count} collections"; + notification.CompletionText = $"Imported {collections.Count} collections"; notification.State = ProgressNotificationState.Completed; } - private void importCollections(List newCollections) + private Task importCollections(List newCollections) { - foreach (var newCol in newCollections) - { - var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name); - if (existing == null) - Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); + var tcs = new TaskCompletionSource(); - foreach (var newBeatmap in newCol.Beatmaps) + Schedule(() => + { + try { - if (!existing.Beatmaps.Contains(newBeatmap)) - existing.Beatmaps.Add(newBeatmap); + foreach (var newCol in newCollections) + { + var existing = Collections.FirstOrDefault(c => c.Name.Value == newCol.Name.Value); + if (existing == null) + Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); + + foreach (var newBeatmap in newCol.Beatmaps) + { + if (!existing.Beatmaps.Contains(newBeatmap)) + existing.Beatmaps.Add(newBeatmap); + } + } + + tcs.SetResult(true); } - } + catch (Exception e) + { + Logger.Error(e, "Failed to import collection."); + tcs.SetException(e); + } + }); + + return tcs.Task; } private List readCollections(Stream stream, ProgressNotification notification = null) diff --git a/osu.Game/Configuration/ModSettingChangeTracker.cs b/osu.Game/Configuration/ModSettingChangeTracker.cs new file mode 100644 index 0000000000..e2ade7dc6a --- /dev/null +++ b/osu.Game/Configuration/ModSettingChangeTracker.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Configuration +{ + /// + /// A helper class for tracking changes to the settings of a set of s. + /// + /// + /// Ensure to dispose when usage is finished. + /// + public class ModSettingChangeTracker : IDisposable + { + /// + /// Notifies that the setting of a has changed. + /// + public Action SettingChanged; + + private readonly List settings = new List(); + + /// + /// Creates a new for a set of s. + /// + /// The set of s whose settings need to be tracked. + public ModSettingChangeTracker(IEnumerable mods) + { + foreach (var mod in mods) + { + foreach (var setting in mod.CreateSettingsControls().OfType()) + { + setting.SettingChanged += () => SettingChanged?.Invoke(mod); + settings.Add(setting); + } + } + } + + public void Dispose() + { + SettingChanged = null; + + foreach (var r in settings) + r.Dispose(); + settings.Clear(); + } + } +} diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index d0fa45bb7a..387cfbb193 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -24,126 +24,126 @@ namespace osu.Game.Configuration protected override void InitialiseDefaults() { // UI/selection defaults - Set(OsuSetting.Ruleset, 0, 0, int.MaxValue); - Set(OsuSetting.Skin, 0, -1, int.MaxValue); + SetDefault(OsuSetting.Ruleset, 0, 0, int.MaxValue); + SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue); - Set(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); - Set(OsuSetting.BeatmapDetailModsFilter, false); + SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); + SetDefault(OsuSetting.BeatmapDetailModsFilter, false); - Set(OsuSetting.ShowConvertedBeatmaps, true); - Set(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); - Set(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); + SetDefault(OsuSetting.ShowConvertedBeatmaps, true); + SetDefault(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); + SetDefault(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); - Set(OsuSetting.SongSelectGroupingMode, GroupMode.All); - Set(OsuSetting.SongSelectSortingMode, SortMode.Title); + SetDefault(OsuSetting.SongSelectGroupingMode, GroupMode.All); + SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title); - Set(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); + SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); - Set(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); + SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); // Online settings - Set(OsuSetting.Username, string.Empty); - Set(OsuSetting.Token, string.Empty); + SetDefault(OsuSetting.Username, string.Empty); + SetDefault(OsuSetting.Token, string.Empty); - Set(OsuSetting.AutomaticallyDownloadWhenSpectating, false); + SetDefault(OsuSetting.AutomaticallyDownloadWhenSpectating, false); - Set(OsuSetting.SavePassword, false).ValueChanged += enabled => + SetDefault(OsuSetting.SavePassword, false).ValueChanged += enabled => { - if (enabled.NewValue) Set(OsuSetting.SaveUsername, true); + if (enabled.NewValue) SetValue(OsuSetting.SaveUsername, true); }; - Set(OsuSetting.SaveUsername, true).ValueChanged += enabled => + SetDefault(OsuSetting.SaveUsername, true).ValueChanged += enabled => { - if (!enabled.NewValue) Set(OsuSetting.SavePassword, false); + if (!enabled.NewValue) SetValue(OsuSetting.SavePassword, false); }; - Set(OsuSetting.ExternalLinkWarning, true); - Set(OsuSetting.PreferNoVideo, false); + SetDefault(OsuSetting.ExternalLinkWarning, true); + SetDefault(OsuSetting.PreferNoVideo, false); - Set(OsuSetting.ShowOnlineExplicitContent, false); + SetDefault(OsuSetting.ShowOnlineExplicitContent, false); // Audio - Set(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); + SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); - Set(OsuSetting.MenuVoice, true); - Set(OsuSetting.MenuMusic, true); + SetDefault(OsuSetting.MenuVoice, true); + SetDefault(OsuSetting.MenuMusic, true); - Set(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); + SetDefault(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); // Input - Set(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); - Set(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); - Set(OsuSetting.AutoCursorSize, false); + SetDefault(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); + SetDefault(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); + SetDefault(OsuSetting.AutoCursorSize, false); - Set(OsuSetting.MouseDisableButtons, false); - Set(OsuSetting.MouseDisableWheel, false); - Set(OsuSetting.ConfineMouseMode, OsuConfineMouseMode.DuringGameplay); + SetDefault(OsuSetting.MouseDisableButtons, false); + SetDefault(OsuSetting.MouseDisableWheel, false); + SetDefault(OsuSetting.ConfineMouseMode, OsuConfineMouseMode.DuringGameplay); // Graphics - Set(OsuSetting.ShowFpsDisplay, false); + SetDefault(OsuSetting.ShowFpsDisplay, false); - Set(OsuSetting.ShowStoryboard, true); - Set(OsuSetting.BeatmapSkins, true); - Set(OsuSetting.BeatmapColours, true); - Set(OsuSetting.BeatmapHitsounds, true); + SetDefault(OsuSetting.ShowStoryboard, true); + SetDefault(OsuSetting.BeatmapSkins, true); + SetDefault(OsuSetting.BeatmapColours, true); + SetDefault(OsuSetting.BeatmapHitsounds, true); - Set(OsuSetting.CursorRotation, true); + SetDefault(OsuSetting.CursorRotation, true); - Set(OsuSetting.MenuParallax, true); + SetDefault(OsuSetting.MenuParallax, true); // Gameplay - Set(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); - Set(OsuSetting.BlurLevel, 0, 0, 1, 0.01); - Set(OsuSetting.LightenDuringBreaks, true); + SetDefault(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); + SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); + SetDefault(OsuSetting.LightenDuringBreaks, true); - Set(OsuSetting.HitLighting, true); + SetDefault(OsuSetting.HitLighting, true); - Set(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); - Set(OsuSetting.ShowProgressGraph, true); - Set(OsuSetting.ShowHealthDisplayWhenCantFail, true); - Set(OsuSetting.FadePlayfieldWhenHealthLow, true); - Set(OsuSetting.KeyOverlay, false); - Set(OsuSetting.PositionalHitSounds, true); - Set(OsuSetting.AlwaysPlayFirstComboBreak, true); - Set(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth); + SetDefault(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); + SetDefault(OsuSetting.ShowProgressGraph, true); + SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); + SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); + SetDefault(OsuSetting.KeyOverlay, false); + SetDefault(OsuSetting.PositionalHitSounds, true); + SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); + SetDefault(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth); - Set(OsuSetting.FloatingComments, false); + SetDefault(OsuSetting.FloatingComments, false); - Set(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised); + SetDefault(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised); - Set(OsuSetting.IncreaseFirstObjectVisibility, true); - Set(OsuSetting.GameplayDisableWinKey, true); + SetDefault(OsuSetting.IncreaseFirstObjectVisibility, true); + SetDefault(OsuSetting.GameplayDisableWinKey, true); // Update - Set(OsuSetting.ReleaseStream, ReleaseStream.Lazer); + SetDefault(OsuSetting.ReleaseStream, ReleaseStream.Lazer); - Set(OsuSetting.Version, string.Empty); + SetDefault(OsuSetting.Version, string.Empty); - Set(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); - Set(OsuSetting.ScreenshotCaptureMenuCursor, false); + SetDefault(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); + SetDefault(OsuSetting.ScreenshotCaptureMenuCursor, false); - Set(OsuSetting.SongSelectRightMouseScroll, false); + SetDefault(OsuSetting.SongSelectRightMouseScroll, false); - Set(OsuSetting.Scaling, ScalingMode.Off); + SetDefault(OsuSetting.Scaling, ScalingMode.Off); - Set(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); - Set(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); + SetDefault(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); + SetDefault(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); - Set(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f); - Set(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f); + SetDefault(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f); + SetDefault(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f); - Set(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); + SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); - Set(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f); + SetDefault(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f); - Set(OsuSetting.IntroSequence, IntroSequence.Triangles); + SetDefault(OsuSetting.IntroSequence, IntroSequence.Triangles); - Set(OsuSetting.MenuBackgroundSource, BackgroundSource.Skin); - Set(OsuSetting.SeasonalBackgroundMode, SeasonalBackgroundMode.Sometimes); + SetDefault(OsuSetting.MenuBackgroundSource, BackgroundSource.Skin); + SetDefault(OsuSetting.SeasonalBackgroundMode, SeasonalBackgroundMode.Sometimes); - Set(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); + SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); - Set(OsuSetting.EditorWaveformOpacity, 1f); + SetDefault(OsuSetting.EditorWaveformOpacity, 1f); } public OsuConfigManager(Storage storage) diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs index b9499c758e..ddbd2327c2 100644 --- a/osu.Game/Configuration/ScoreMeterType.cs +++ b/osu.Game/Configuration/ScoreMeterType.cs @@ -16,12 +16,12 @@ namespace osu.Game.Configuration [Description("Hit Error (right)")] HitErrorRight, - [Description("Hit Error (bottom)")] - HitErrorBottom, - [Description("Hit Error (left+right)")] HitErrorBoth, + [Description("Hit Error (bottom)")] + HitErrorBottom, + [Description("Colour (left)")] ColourLeft, diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index fd401119ff..36eb6964dd 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -14,10 +14,10 @@ namespace osu.Game.Configuration { protected override void InitialiseDefaults() { - Set(Static.LoginOverlayDisplayed, false); - Set(Static.MutedAudioNotificationShownOnce, false); - Set(Static.LastHoverSoundPlaybackTime, (double?)null); - Set(Static.SeasonalBackgrounds, null); + SetDefault(Static.LoginOverlayDisplayed, false); + SetDefault(Static.MutedAudioNotificationShownOnce, false); + SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); + SetDefault(Static.SeasonalBackgrounds, null); } } diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 50069be4b2..cfce615130 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -8,6 +8,7 @@ using System.Reflection; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Overlays.Settings; namespace osu.Game.Configuration @@ -22,9 +23,9 @@ namespace osu.Game.Configuration /// [MeansImplicitUse] [AttributeUsage(AttributeTargets.Property)] - public class SettingSourceAttribute : Attribute + public class SettingSourceAttribute : Attribute, IComparable { - public string Label { get; } + public LocalisableString Label { get; } public string Description { get; } @@ -41,6 +42,21 @@ namespace osu.Game.Configuration { OrderPosition = orderPosition; } + + public int CompareTo(SettingSourceAttribute other) + { + if (OrderPosition == other.OrderPosition) + return 0; + + // unordered items come last (are greater than any ordered items). + if (OrderPosition == null) + return 1; + if (other.OrderPosition == null) + return -1; + + // ordered items are sorted by the order value. + return OrderPosition.Value.CompareTo(other.OrderPosition); + } } public static class SettingSourceExtensions @@ -57,6 +73,7 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, + TooltipText = attr.Description, Current = bNumber, KeyboardStep = 0.1f, }; @@ -67,6 +84,7 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, + TooltipText = attr.Description, Current = bNumber, KeyboardStep = 0.1f, }; @@ -77,6 +95,7 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, + TooltipText = attr.Description, Current = bNumber }; @@ -86,6 +105,7 @@ namespace osu.Game.Configuration yield return new SettingsCheckbox { LabelText = attr.Label, + TooltipText = attr.Description, Current = bBool }; @@ -95,6 +115,7 @@ namespace osu.Game.Configuration yield return new SettingsTextBox { LabelText = attr.Label, + TooltipText = attr.Description, Current = bString }; @@ -105,6 +126,7 @@ namespace osu.Game.Configuration var dropdown = (Drawable)Activator.CreateInstance(dropdownType); dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); + dropdownType.GetProperty(nameof(SettingsDropdown.TooltipText))?.SetValue(dropdown, attr.Description); dropdownType.GetProperty(nameof(SettingsDropdown.Current))?.SetValue(dropdown, bindable); yield return dropdown; @@ -130,14 +152,9 @@ namespace osu.Game.Configuration } } - public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetOrderedSettingsSourceProperties(this object obj) - { - var original = obj.GetSettingsSourceProperties(); - - var orderedRelative = original.Where(attr => attr.Item1.OrderPosition != null).OrderBy(attr => attr.Item1.OrderPosition); - var unordered = original.Except(orderedRelative); - - return orderedRelative.Concat(unordered); - } + public static ICollection<(SettingSourceAttribute, PropertyInfo)> GetOrderedSettingsSourceProperties(this object obj) + => obj.GetSettingsSourceProperties() + .OrderBy(attr => attr.Item1) + .ToArray(); } } diff --git a/osu.Game/Configuration/StorageConfigManager.cs b/osu.Game/Configuration/StorageConfigManager.cs index 929f8f22ad..90ea42b638 100644 --- a/osu.Game/Configuration/StorageConfigManager.cs +++ b/osu.Game/Configuration/StorageConfigManager.cs @@ -19,7 +19,7 @@ namespace osu.Game.Configuration { base.InitialiseDefaults(); - Set(StorageConfig.FullPath, string.Empty); + SetDefault(StorageConfig.FullPath, string.Empty); } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 9f69ad035f..64428882ac 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -22,7 +22,6 @@ using osu.Game.IO.Archives; using osu.Game.IPC; using osu.Game.Overlays.Notifications; using SharpCompress.Archives.Zip; -using FileInfo = osu.Game.IO.FileInfo; namespace osu.Game.Database { @@ -38,6 +37,11 @@ namespace osu.Game.Database { private const int import_queue_request_concurrency = 1; + /// + /// The size of a batch import operation before considering it a lower priority operation. + /// + private const int low_priority_import_batch_size = 1; + /// /// A singleton scheduler shared by all . /// @@ -47,6 +51,13 @@ namespace osu.Game.Database /// private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); + /// + /// A second scheduler for lower priority imports. + /// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue. + /// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this. + /// + private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); + /// /// Set an endpoint for notifications to be posted to. /// @@ -103,8 +114,11 @@ namespace osu.Game.Database /// /// Import one or more items from filesystem . - /// This will post notifications tracking progress. /// + /// + /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. + /// This will post notifications tracking progress. + /// /// One or more archive locations on disk. public Task Import(params string[] paths) { @@ -126,6 +140,13 @@ namespace osu.Game.Database protected async Task> Import(ProgressNotification notification, params ImportTask[] tasks) { + if (tasks.Length == 0) + { + notification.CompletionText = $"No {HumanisedModelName}s were found to import!"; + notification.State = ProgressNotificationState.Completed; + return Enumerable.Empty(); + } + notification.Progress = 0; notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; @@ -133,13 +154,15 @@ namespace osu.Game.Database var imported = new List(); + bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; + await Task.WhenAll(tasks.Select(async task => { notification.CancellationToken.ThrowIfCancellationRequested(); try { - var model = await Import(task, notification.CancellationToken); + var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); lock (imported) { @@ -159,7 +182,7 @@ namespace osu.Game.Database { Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); } - })); + })).ConfigureAwait(false); if (imported.Count == 0) { @@ -193,15 +216,16 @@ namespace osu.Game.Database /// Note that this bypasses the UI flow and should only be used for special cases or testing. /// /// The containing data about the to import. + /// Whether this is a low priority import. /// An optional cancellation token. /// The imported model, if successful. - internal async Task Import(ImportTask task, CancellationToken cancellationToken = default) + internal async Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); TModel import; using (ArchiveReader reader = task.GetReader()) - import = await Import(reader, cancellationToken); + import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false); // We may or may not want to delete the file depending on where it is stored. // e.g. reconstructing/repairing database with items from default storage. @@ -226,11 +250,12 @@ namespace osu.Game.Database public Action> PresentImport; /// - /// Import an item from an . + /// Silently import an item from an . /// /// The archive to be imported. + /// Whether this is a low priority import. /// An optional cancellation token. - public Task Import(ArchiveReader archive, CancellationToken cancellationToken = default) + public Task Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -253,7 +278,7 @@ namespace osu.Game.Database return null; } - return Import(model, archive, cancellationToken); + return Import(model, archive, lowPriority, cancellationToken); } /// @@ -303,12 +328,13 @@ namespace osu.Game.Database } /// - /// Import an item from a . + /// Silently import an item from a . /// /// The model to be imported. /// An optional archive to use for model population. + /// Whether this is a low priority import. /// An optional cancellation token. - public async Task Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => + public virtual async Task Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => { cancellationToken.ThrowIfCancellationRequested(); @@ -331,7 +357,7 @@ namespace osu.Game.Database item.Files = archive != null ? createFileInfos(archive, Files) : new List(); item.Hash = ComputeHash(item, archive); - await Populate(item, archive, cancellationToken); + await Populate(item, archive, cancellationToken).ConfigureAwait(false); using (var write = ContextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes. { @@ -383,7 +409,7 @@ namespace osu.Game.Database flushEvents(true); return item; - }, cancellationToken, TaskCreationOptions.HideScheduler, import_scheduler).Unwrap(); + }, cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap().ConfigureAwait(false); /// /// Exports an item to a legacy (.zip based) package. @@ -436,6 +462,8 @@ namespace osu.Game.Database // Dereference the existing file info, since the file model will be removed. if (file.FileInfo != null) { + file.Requery(usage.Context); + Files.Dereference(file.FileInfo); // This shouldn't be required, but here for safety in case the provided TModel is not being change tracked @@ -594,7 +622,7 @@ namespace osu.Game.Database } /// - /// Create all required s for the provided archive, adding them to the global file store. + /// Create all required s for the provided archive, adding them to the global file store. /// private List createFileInfos(ArchiveReader reader, FileStore files) { @@ -609,10 +637,12 @@ namespace osu.Game.Database { using (Stream s = reader.GetStream(file)) { + var fileInfo = files.Add(s); fileInfos.Add(new TFileModel { Filename = file.Substring(prefix.Length).ToStandardisedPath(), - FileInfo = files.Add(s) + FileInfo = fileInfo, + FileInfoID = fileInfo.ID // workaround for efcore 5 compatibility. }); } } @@ -625,7 +655,7 @@ namespace osu.Game.Database /// /// Set a storage with access to an osu-stable install for import purposes. /// - public Func GetStableStorage { private get; set; } + public Func GetStableStorage { private get; set; } /// /// Denotes whether an osu-stable installation is present to perform automated imports from. @@ -638,9 +668,10 @@ namespace osu.Game.Database protected virtual string ImportFromStablePath => null; /// - /// Select paths to import from stable. Default implementation iterates all directories in . + /// Select paths to import from stable where all paths should be absolute. Default implementation iterates all directories in . /// - protected virtual IEnumerable GetStableImportPaths(Storage stableStoage) => stableStoage.GetDirectories(ImportFromStablePath); + protected virtual IEnumerable GetStableImportPaths(Storage storage) => storage.GetDirectories(ImportFromStablePath) + .Select(path => storage.GetFullPath(path)); /// /// Whether this specified path should be removed after successful import. @@ -654,24 +685,33 @@ namespace osu.Game.Database /// public Task ImportFromStableAsync() { - var stable = GetStableStorage?.Invoke(); + var stableStorage = GetStableStorage?.Invoke(); - if (stable == null) + if (stableStorage == null) { Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } - if (!stable.ExistsDirectory(ImportFromStablePath)) + var storage = PrepareStableStorage(stableStorage); + + if (!storage.ExistsDirectory(ImportFromStablePath)) { // This handles situations like when the user does not have a Skins folder Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } - return Task.Run(async () => await Import(GetStableImportPaths(GetStableStorage()).Select(f => stable.GetFullPath(f)).ToArray())); + return Task.Run(async () => await Import(GetStableImportPaths(storage).ToArray()).ConfigureAwait(false)); } + /// + /// Run any required traversal operations on the stable storage location before performing operations. + /// + /// The stable storage. + /// The usable storage. Return the unchanged if no traversal is required. + protected virtual Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage; + #endregion /// diff --git a/osu.Game/Database/DatabaseWorkaroundExtensions.cs b/osu.Game/Database/DatabaseWorkaroundExtensions.cs new file mode 100644 index 0000000000..a3a982f232 --- /dev/null +++ b/osu.Game/Database/DatabaseWorkaroundExtensions.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 System; +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Database +{ + /// + /// Extension methods which contain workarounds to make EFcore 5.x work with our existing (incorrect) thread safety. + /// The intention is to avoid blocking package updates while we consider the future of the database backend, with a potential backend switch imminent. + /// + public static class DatabaseWorkaroundExtensions + { + /// + /// Re-query the provided model to ensure it is in a sane state. This method requires explicit implementation per model type. + /// + /// + /// + public static void Requery(this IHasPrimaryKey model, IDatabaseContextFactory contextFactory) + { + switch (model) + { + case SkinInfo skinInfo: + requeryFiles(skinInfo.Files, contextFactory); + break; + + case ScoreInfo scoreInfo: + requeryFiles(scoreInfo.Beatmap.BeatmapSet.Files, contextFactory); + requeryFiles(scoreInfo.Files, contextFactory); + break; + + case BeatmapSetInfo beatmapSetInfo: + var context = contextFactory.Get(); + + foreach (var beatmap in beatmapSetInfo.Beatmaps) + { + // Workaround System.InvalidOperationException + // The instance of entity type 'RulesetInfo' cannot be tracked because another instance with the same key value for {'ID'} is already being tracked. + beatmap.Ruleset = context.RulesetInfo.Find(beatmap.RulesetID); + } + + requeryFiles(beatmapSetInfo.Files, contextFactory); + break; + + default: + throw new ArgumentException($"{nameof(Requery)} does not have support for the provided model type", nameof(model)); + } + + void requeryFiles(List files, IDatabaseContextFactory databaseContextFactory) where T : class, INamedFileInfo + { + var dbContext = databaseContextFactory.Get(); + + foreach (var file in files) + { + Requery(file, dbContext); + } + } + } + + public static void Requery(this INamedFileInfo file, OsuDbContext dbContext) + { + // Workaround System.InvalidOperationException + // The instance of entity type 'FileInfo' cannot be tracked because another instance with the same key value for {'ID'} is already being tracked. + file.FileInfo = dbContext.FileInfo.Find(file.FileInfoID); + } + } +} diff --git a/osu.Game/Database/DatabaseWriteUsage.cs b/osu.Game/Database/DatabaseWriteUsage.cs index ddafd77066..84c39e3532 100644 --- a/osu.Game/Database/DatabaseWriteUsage.cs +++ b/osu.Game/Database/DatabaseWriteUsage.cs @@ -54,10 +54,5 @@ namespace osu.Game.Database Dispose(true); GC.SuppressFinalize(this); } - - ~DatabaseWriteUsage() - { - Dispose(false); - } } } diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/DownloadableArchiveModelManager.cs index 50b022f9ff..da3144e8d0 100644 --- a/osu.Game/Database/DownloadableArchiveModelManager.cs +++ b/osu.Game/Database/DownloadableArchiveModelManager.cs @@ -82,7 +82,7 @@ namespace osu.Game.Database Task.Factory.StartNew(async () => { // This gets scheduled back to the update thread, but we want the import to run in the background. - var imported = await Import(notification, new ImportTask(filename)); + var imported = await Import(notification, new ImportTask(filename)).ConfigureAwait(false); // for now a failed import will be marked as a failed download for simplicity. if (!imported.Any()) diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs index d913e66428..a1a1279d71 100644 --- a/osu.Game/Database/MemoryCachingComponent.cs +++ b/osu.Game/Database/MemoryCachingComponent.cs @@ -29,7 +29,7 @@ namespace osu.Game.Database if (CheckExists(lookup, out TValue performance)) return performance; - var computed = await ComputeValueAsync(lookup, token); + var computed = await ComputeValueAsync(lookup, token).ConfigureAwait(false); if (computed != null || CacheNullValues) cache[lookup] = computed; diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index e0c0f56cb3..4e59faf371 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -3,7 +3,6 @@ using System; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; using osu.Framework.Logging; using osu.Framework.Statistics; @@ -113,10 +112,10 @@ namespace osu.Game.Database { base.OnConfiguring(optionsBuilder); optionsBuilder - // this is required for the time being due to the way we are querying in places like BeatmapStore. - // if we ever move to having consumers file their own .Includes, or get eager loading support, this could be re-enabled. - .ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.IncludeIgnoredWarning)) - .UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(10)) + .UseSqlite(connectionString, + sqliteOptions => sqliteOptions + .CommandTimeout(10) + .UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery)) .UseLoggerFactory(logger.Value); } diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index 568726199c..19cc211709 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -28,7 +28,7 @@ namespace osu.Game.Database public Task GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token); protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) - => await queryUser(lookup); + => await queryUser(lookup).ConfigureAwait(false); private readonly Queue<(int id, TaskCompletionSource)> pendingUserTasks = new Queue<(int, TaskCompletionSource)>(); private Task pendingRequestTask; diff --git a/osu.Game/Extensions/TaskExtensions.cs b/osu.Game/Extensions/TaskExtensions.cs index 4138c2757a..76a76c0c52 100644 --- a/osu.Game/Extensions/TaskExtensions.cs +++ b/osu.Game/Extensions/TaskExtensions.cs @@ -4,33 +4,65 @@ #nullable enable using System; +using System.Threading; using System.Threading.Tasks; -using osu.Framework.Extensions.ExceptionExtensions; -using osu.Framework.Logging; namespace osu.Game.Extensions { public static class TaskExtensions { /// - /// Denote a task which is to be run without local error handling logic, where failure is not catastrophic. - /// Avoids unobserved exceptions from being fired. + /// Add a continuation to be performed only after the attached task has completed. /// - /// The task. - /// - /// Whether errors should be logged as errors visible to users, or as debug messages. - /// Logging as debug will essentially silence the errors on non-release builds. - /// - public static void CatchUnobservedExceptions(this Task task, bool logAsError = false) + /// The previous task to be awaited on. + /// The action to run. + /// An optional cancellation token. Will only cancel the provided action, not the sequence. + /// A task representing the provided action. + public static Task ContinueWithSequential(this Task task, Action action, CancellationToken cancellationToken = default) => + task.ContinueWithSequential(() => Task.Run(action, cancellationToken), cancellationToken); + + /// + /// Add a continuation to be performed only after the attached task has completed. + /// + /// The previous task to be awaited on. + /// The continuation to run. Generally should be an async function. + /// An optional cancellation token. Will only cancel the provided action, not the sequence. + /// A task representing the provided action. + public static Task ContinueWithSequential(this Task task, Func continuationFunction, CancellationToken cancellationToken = default) { + var tcs = new TaskCompletionSource(); + task.ContinueWith(t => { - Exception? exception = t.Exception?.AsSingular(); - if (logAsError) - Logger.Error(exception, $"Error running task: {exception?.Message ?? "(unknown)"}", LoggingTarget.Runtime, true); + // the previous task has finished execution or been cancelled, so we can run the provided continuation. + + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(); + } else - Logger.Log($"Error running task: {exception}", LoggingTarget.Runtime, LogLevel.Debug); - }, TaskContinuationOptions.NotOnRanToCompletion); + { + continuationFunction().ContinueWith(continuationTask => + { + if (cancellationToken.IsCancellationRequested || continuationTask.IsCanceled) + { + tcs.TrySetCanceled(); + } + else if (continuationTask.IsFaulted) + { + tcs.TrySetException(continuationTask.Exception); + } + else + { + tcs.TrySetResult(true); + } + }, cancellationToken: default); + } + }, cancellationToken: default); + + // importantly, we are not returning the continuation itself but rather a task which represents its status in sequential execution order. + // this will not be cancelled or completed until the previous task has also. + return tcs.Task; } } } diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index e3a9a5fe9d..914c8ff78d 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -38,7 +38,12 @@ namespace osu.Game.Graphics.Containers foreach (var link in links) { AddText(text[previousLinkEnd..link.Index]); - AddLink(text.Substring(link.Index, link.Length), link.Action, link.Argument ?? link.Url); + + string displayText = text.Substring(link.Index, link.Length); + string linkArgument = link.Argument ?? link.Url; + string tooltip = displayText == link.Url ? null : link.Url; + + AddLink(displayText, link.Action, linkArgument, tooltip); previousLinkEnd = link.Index + link.Length; } @@ -52,7 +57,7 @@ namespace osu.Game.Graphics.Containers => createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.Custom, null), tooltipText, action); public void AddLink(string text, LinkAction action, string argument, string tooltipText = null, Action creationParameters = null) - => createLink(AddText(text, creationParameters), new LinkDetails(action, argument), null); + => createLink(AddText(text, creationParameters), new LinkDetails(action, argument), tooltipText); public void AddLink(IEnumerable text, LinkAction action = LinkAction.External, string linkArgument = null, string tooltipText = null) { diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 41fd37a0d7..fbf2ffd4bd 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -18,8 +18,10 @@ namespace osu.Game.Graphics.Containers [Cached(typeof(IPreviewTrackOwner))] public abstract class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { - private SampleChannel samplePopIn; - private SampleChannel samplePopOut; + private Sample samplePopIn; + private Sample samplePopOut; + protected virtual string PopInSampleName => "UI/overlay-pop-in"; + protected virtual string PopOutSampleName => "UI/overlay-pop-out"; protected override bool BlockNonPositionalInput => true; @@ -40,8 +42,8 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader(true)] private void load(AudioManager audio) { - samplePopIn = audio.Samples.Get(@"UI/overlay-pop-in"); - samplePopOut = audio.Samples.Get(@"UI/overlay-pop-out"); + samplePopIn = audio.Samples.Get(PopInSampleName); + samplePopOut = audio.Samples.Get(PopOutSampleName); } protected override void LoadComplete() diff --git a/osu.Game/Graphics/Containers/ParallaxContainer.cs b/osu.Game/Graphics/Containers/ParallaxContainer.cs index 4cd3934cde..b501e68ba1 100644 --- a/osu.Game/Graphics/Containers/ParallaxContainer.cs +++ b/osu.Game/Graphics/Containers/ParallaxContainer.cs @@ -24,6 +24,10 @@ namespace osu.Game.Graphics.Containers private Bindable parallaxEnabled; + private const float parallax_duration = 100; + + private bool firstUpdate = true; + public ParallaxContainer() { RelativeSizeAxes = Axes.Both; @@ -60,17 +64,27 @@ namespace osu.Game.Graphics.Containers input = GetContainingInputManager(); } - private bool firstUpdate = true; - protected override void Update() { base.Update(); if (parallaxEnabled.Value) { - Vector2 offset = (input.CurrentState.Mouse == null ? Vector2.Zero : ToLocalSpace(input.CurrentState.Mouse.Position) - DrawSize / 2) * ParallaxAmount; + Vector2 offset = Vector2.Zero; - const float parallax_duration = 100; + if (input.CurrentState.Mouse != null) + { + var sizeDiv2 = DrawSize / 2; + + Vector2 relativeAmount = ToLocalSpace(input.CurrentState.Mouse.Position) - sizeDiv2; + + const float base_factor = 0.999f; + + relativeAmount.X = (float)(Math.Sign(relativeAmount.X) * Interpolation.Damp(0, 1, base_factor, Math.Abs(relativeAmount.X))); + relativeAmount.Y = (float)(Math.Sign(relativeAmount.Y) * Interpolation.Damp(0, 1, base_factor, Math.Abs(relativeAmount.Y))); + + offset = relativeAmount * sizeDiv2 * ParallaxAmount; + } double elapsed = Math.Clamp(Clock.ElapsedFrameTime, 0, parallax_duration); diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 81968de304..8ab146efe7 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.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; @@ -9,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; +using osu.Framework.Utils; namespace osu.Game.Graphics.Containers { @@ -20,6 +22,7 @@ namespace osu.Game.Graphics.Containers where T : Drawable { public Bindable SelectedSection { get; } = new Bindable(); + private Drawable lastClickedSection; public Drawable ExpandableHeader { @@ -36,7 +39,7 @@ namespace osu.Game.Graphics.Containers if (value == null) return; AddInternal(expandableHeader); - lastKnownScroll = float.NaN; + lastKnownScroll = null; } } @@ -52,7 +55,7 @@ namespace osu.Game.Graphics.Containers if (value == null) return; AddInternal(fixedHeader); - lastKnownScroll = float.NaN; + lastKnownScroll = null; } } @@ -71,7 +74,7 @@ namespace osu.Game.Graphics.Containers footer.Anchor |= Anchor.y2; footer.Origin |= Anchor.y2; scrollContainer.Add(footer); - lastKnownScroll = float.NaN; + lastKnownScroll = null; } } @@ -89,21 +92,26 @@ namespace osu.Game.Graphics.Containers headerBackgroundContainer.Add(headerBackground); - lastKnownScroll = float.NaN; + lastKnownScroll = null; } } protected override Container Content => scrollContentContainer; - private readonly OsuScrollContainer scrollContainer; + private readonly UserTrackingScrollContainer scrollContainer; private readonly Container headerBackgroundContainer; private readonly MarginPadding originalSectionsMargin; private Drawable expandableHeader, fixedHeader, footer, headerBackground; private FlowContainer scrollContentContainer; - private float headerHeight, footerHeight; + private float? headerHeight, footerHeight; - private float lastKnownScroll; + private float? lastKnownScroll; + + /// + /// The percentage of the container to consider the centre-point for deciding the active section (and scrolling to a requested section). + /// + private const float scroll_y_centre = 0.1f; public SectionsContainer() { @@ -128,18 +136,24 @@ namespace osu.Game.Graphics.Containers public override void Add(T drawable) { base.Add(drawable); - lastKnownScroll = float.NaN; - headerHeight = float.NaN; - footerHeight = float.NaN; + + Debug.Assert(drawable != null); + + lastKnownScroll = null; + headerHeight = null; + footerHeight = null; } - public void ScrollTo(Drawable section) => - scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - (FixedHeader?.BoundingBox.Height ?? 0)); + public void ScrollTo(Drawable section) + { + lastClickedSection = section; + scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - scrollContainer.DisplayableContent * scroll_y_centre - (FixedHeader?.BoundingBox.Height ?? 0)); + } public void ScrollToTop() => scrollContainer.ScrollTo(0); [NotNull] - protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + protected virtual UserTrackingScrollContainer CreateScrollContainer() => new UserTrackingScrollContainer(); [NotNull] protected virtual FlowContainer CreateScrollContentContainer() => @@ -156,7 +170,7 @@ namespace osu.Game.Graphics.Containers if (source == InvalidationSource.Child && (invalidation & Invalidation.DrawSize) != 0) { - lastKnownScroll = -1; + lastKnownScroll = null; result = true; } @@ -167,7 +181,10 @@ namespace osu.Game.Graphics.Containers { base.UpdateAfterChildren(); - float headerH = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0); + float fixedHeaderSize = FixedHeader?.LayoutSize.Y ?? 0; + float expandableHeaderSize = ExpandableHeader?.LayoutSize.Y ?? 0; + + float headerH = expandableHeaderSize + fixedHeaderSize; float footerH = Footer?.LayoutSize.Y ?? 0; if (headerH != headerHeight || footerH != footerHeight) @@ -183,28 +200,39 @@ namespace osu.Game.Graphics.Containers { lastKnownScroll = currentScroll; + // reset last clicked section because user started scrolling themselves + if (scrollContainer.UserScrolling) + lastClickedSection = null; + if (ExpandableHeader != null && FixedHeader != null) { - float offset = Math.Min(ExpandableHeader.LayoutSize.Y, currentScroll); + float offset = Math.Min(expandableHeaderSize, currentScroll); ExpandableHeader.Y = -offset; - FixedHeader.Y = -offset + ExpandableHeader.LayoutSize.Y; + FixedHeader.Y = -offset + expandableHeaderSize; } - headerBackgroundContainer.Height = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0); + headerBackgroundContainer.Height = expandableHeaderSize + fixedHeaderSize; headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0; - float scrollOffset = FixedHeader?.LayoutSize.Y ?? 0; - Func diff = section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset; + var smallestSectionHeight = Children.Count > 0 ? Children.Min(d => d.Height) : 0; - if (scrollContainer.IsScrolledToEnd()) - { - SelectedSection.Value = Children.LastOrDefault(); - } + // scroll offset is our fixed header height if we have it plus 10% of content height + // plus 5% to fix floating point errors and to not have a section instantly unselect when scrolling upwards + // but the 5% can't be bigger than our smallest section height, otherwise it won't get selected correctly + float selectionLenienceAboveSection = Math.Min(smallestSectionHeight / 2.0f, scrollContainer.DisplayableContent * 0.05f); + + float scrollCentre = fixedHeaderSize + scrollContainer.DisplayableContent * scroll_y_centre + selectionLenienceAboveSection; + + if (Precision.AlmostBigger(0, scrollContainer.Current)) + SelectedSection.Value = lastClickedSection as T ?? Children.FirstOrDefault(); + else if (Precision.AlmostBigger(scrollContainer.Current, scrollContainer.ScrollableExtent)) + SelectedSection.Value = lastClickedSection as T ?? Children.LastOrDefault(); else { - SelectedSection.Value = Children.TakeWhile(section => diff(section) <= 0).LastOrDefault() - ?? Children.FirstOrDefault(); + SelectedSection.Value = Children + .TakeWhile(section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollCentre <= 0) + .LastOrDefault() ?? Children.FirstOrDefault(); } } } @@ -214,8 +242,9 @@ namespace osu.Game.Graphics.Containers if (!Children.Any()) return; var newMargin = originalSectionsMargin; - newMargin.Top += headerHeight; - newMargin.Bottom += footerHeight; + + newMargin.Top += (headerHeight ?? 0); + newMargin.Bottom += (footerHeight ?? 0); scrollContentContainer.Margin = newMargin; } diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs new file mode 100644 index 0000000000..17506ce0f5 --- /dev/null +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -0,0 +1,57 @@ +// 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; + +namespace osu.Game.Graphics.Containers +{ + public class UserTrackingScrollContainer : UserTrackingScrollContainer + { + public UserTrackingScrollContainer() + { + } + + public UserTrackingScrollContainer(Direction direction) + : base(direction) + { + } + } + + public class UserTrackingScrollContainer : OsuScrollContainer + where T : Drawable + { + /// + /// Whether the last scroll event was user triggered, directly on the scroll container. + /// + public bool UserScrolling { get; private set; } + + public void CancelUserScroll() => UserScrolling = false; + + public UserTrackingScrollContainer() + { + } + + public UserTrackingScrollContainer(Direction direction) + : base(direction) + { + } + + protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + { + UserScrolling = true; + base.OnUserScroll(value, animated, distanceDecay); + } + + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + { + UserScrolling = false; + base.ScrollTo(value, animated, distanceDecay); + } + + public new void ScrollToEnd(bool animated = true, bool allowDuringDrag = false) + { + UserScrolling = false; + base.ScrollToEnd(animated, allowDuringDrag); + } + } +} diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 53ee711626..fb7fe4947b 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -44,7 +44,7 @@ namespace osu.Game.Graphics [Resolved] private NotificationOverlay notificationOverlay { get; set; } - private SampleChannel shutter; + private Sample shutter; [BackgroundDependencyLoader] private void load(OsuConfigManager config, Storage storage, AudioManager audio) @@ -103,7 +103,7 @@ namespace osu.Game.Graphics } } - using (var image = await host.TakeScreenshotAsync()) + using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false)) { if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false) cursorVisibility.Value = true; @@ -116,13 +116,13 @@ namespace osu.Game.Graphics switch (screenshotFormat.Value) { case ScreenshotFormat.Png: - await image.SaveAsPngAsync(stream); + await image.SaveAsPngAsync(stream).ConfigureAwait(false); break; case ScreenshotFormat.Jpg: const int jpeg_quality = 92; - await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }); + await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); break; default: diff --git a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs index 85df2d167f..fb273d7293 100644 --- a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs +++ b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osuTK; namespace osu.Game.Graphics.Sprites @@ -14,7 +15,7 @@ namespace osu.Game.Graphics.Sprites { private readonly OsuSpriteText spriteText, blurredText; - public string Text + public LocalisableString Text { get => spriteText.Text; set => blurredText.Text = spriteText.Text = value; diff --git a/osu.Game/Graphics/UserInterface/BarGraph.cs b/osu.Game/Graphics/UserInterface/BarGraph.cs index 953f3985f9..407bf6a923 100644 --- a/osu.Game/Graphics/UserInterface/BarGraph.cs +++ b/osu.Game/Graphics/UserInterface/BarGraph.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; namespace osu.Game.Graphics.UserInterface { @@ -24,11 +25,11 @@ namespace osu.Game.Graphics.UserInterface set { direction = value; - base.Direction = direction.HasFlag(BarDirection.Horizontal) ? FillDirection.Vertical : FillDirection.Horizontal; + base.Direction = direction.HasFlagFast(BarDirection.Horizontal) ? FillDirection.Vertical : FillDirection.Horizontal; foreach (var bar in Children) { - bar.Size = direction.HasFlag(BarDirection.Horizontal) ? new Vector2(1, 1.0f / Children.Count) : new Vector2(1.0f / Children.Count, 1); + bar.Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, 1.0f / Children.Count) : new Vector2(1.0f / Children.Count, 1); bar.Direction = direction; } } @@ -56,14 +57,14 @@ namespace osu.Game.Graphics.UserInterface if (bar.Bar != null) { bar.Bar.Length = length; - bar.Bar.Size = direction.HasFlag(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1); + bar.Bar.Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1); } else { Add(new Bar { RelativeSizeAxes = Axes.Both, - Size = direction.HasFlag(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1), + Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1), Length = length, Direction = Direction, }); diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index 5168ff646b..7a8db158c1 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -4,54 +4,38 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Online; using osuTK; namespace osu.Game.Graphics.UserInterface { - public class DownloadButton : OsuAnimatedButton + public class DownloadButton : GrayButton { - public readonly Bindable State = new Bindable(); - - private readonly SpriteIcon icon; - private readonly SpriteIcon checkmark; - private readonly Box background; - [Resolved] private OsuColour colours { get; set; } + public readonly Bindable State = new Bindable(); + + private SpriteIcon checkmark; + public DownloadButton() + : base(FontAwesome.Solid.Download) { - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Depth = float.MaxValue - }, - icon = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(13), - Icon = FontAwesome.Solid.Download, - }, - checkmark = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - X = 8, - Size = Vector2.Zero, - Icon = FontAwesome.Solid.Check, - } - }; } [BackgroundDependencyLoader] private void load() { + AddInternal(checkmark = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = 8, + Size = Vector2.Zero, + Icon = FontAwesome.Solid.Check, + }); + State.BindValueChanged(updateState, true); } @@ -60,27 +44,27 @@ namespace osu.Game.Graphics.UserInterface switch (state.NewValue) { case DownloadState.NotDownloaded: - background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); - icon.MoveToX(0, 500, Easing.InOutExpo); + Background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); + Icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); TooltipText = "Download"; break; case DownloadState.Downloading: - background.FadeColour(colours.Blue, 500, Easing.InOutExpo); - icon.MoveToX(0, 500, Easing.InOutExpo); + Background.FadeColour(colours.Blue, 500, Easing.InOutExpo); + Icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); TooltipText = "Downloading..."; break; case DownloadState.Importing: - background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); + Background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); TooltipText = "Importing"; break; case DownloadState.LocallyAvailable: - background.FadeColour(colours.Green, 500, Easing.InOutExpo); - icon.MoveToX(-8, 500, Easing.InOutExpo); + Background.FadeColour(colours.Green, 500, Easing.InOutExpo); + Icon.MoveToX(-8, 500, Easing.InOutExpo); checkmark.ScaleTo(new Vector2(13), 500, Easing.InOutExpo); break; } diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index abaae7b43c..8df2c1c2fd 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -22,8 +23,8 @@ namespace osu.Game.Graphics.UserInterface private const int text_size = 17; private const int transition_length = 80; - private SampleChannel sampleClick; - private SampleChannel sampleHover; + private Sample sampleClick; + private Sample sampleHover; private TextContainer text; @@ -105,7 +106,7 @@ namespace osu.Game.Graphics.UserInterface protected class TextContainer : Container, IHasText { - public string Text + public LocalisableString Text { get => NormalText.Text; set diff --git a/osu.Game/Graphics/UserInterface/GrayButton.cs b/osu.Game/Graphics/UserInterface/GrayButton.cs new file mode 100644 index 0000000000..88c46f29e0 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/GrayButton.cs @@ -0,0 +1,48 @@ +// 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; +using osu.Framework.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public class GrayButton : OsuAnimatedButton + { + protected SpriteIcon Icon { get; private set; } + protected Box Background { get; private set; } + + private readonly IconUsage icon; + + [Resolved] + private OsuColour colours { get; set; } + + public GrayButton(IconUsage icon) + { + this.icon = icon; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + Background = new Box + { + Colour = colours.Gray4, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }, + Icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(13), + Icon = icon, + }, + }; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index 803facae04..c1963ce62d 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -17,7 +17,7 @@ namespace osu.Game.Graphics.UserInterface /// public class HoverClickSounds : HoverSounds { - private SampleChannel sampleClick; + private Sample sampleClick; private readonly MouseButton[] buttons; /// diff --git a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs new file mode 100644 index 0000000000..55f43cfe46 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.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.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Configuration; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// Handles debouncing hover sounds at a global level to ensure the effects are not overwhelming. + /// + public abstract class HoverSampleDebounceComponent : CompositeDrawable + { + /// + /// Length of debounce for hover sound playback, in milliseconds. + /// + public double HoverDebounceTime { get; } = 20; + + private Bindable lastPlaybackTime; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, SessionStatics statics) + { + lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); + } + + protected override bool OnHover(HoverEvent e) + { + // hover sounds shouldn't be played during scroll operations. + if (e.HasAnyButtonPressed) + return false; + + bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= HoverDebounceTime; + + if (enoughTimePassedSinceLastPlayback) + { + PlayHoverSample(); + lastPlaybackTime.Value = Time.Current; + } + + return false; + } + + public abstract void PlayHoverSample(); + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index a1d06711db..00cf5798e7 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -5,12 +5,10 @@ using System.ComponentModel; 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; using osu.Game.Configuration; +using osu.Framework.Utils; namespace osu.Game.Graphics.UserInterface { @@ -18,19 +16,12 @@ namespace osu.Game.Graphics.UserInterface /// Adds hover sounds to a drawable. /// Does not draw anything. /// - public class HoverSounds : CompositeDrawable + public class HoverSounds : HoverSampleDebounceComponent { - private SampleChannel sampleHover; - - /// - /// Length of debounce for hover sound playback, in milliseconds. - /// - public double HoverDebounceTime { get; } = 20; + private Sample sampleHover; protected readonly HoverSampleSet SampleSet; - private Bindable lastPlaybackTime; - public HoverSounds(HoverSampleSet sampleSet = HoverSampleSet.Normal) { SampleSet = sampleSet; @@ -40,22 +31,13 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio, SessionStatics statics) { - lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); - sampleHover = audio.Samples.Get($@"UI/generic-hover{SampleSet.GetDescription()}"); } - protected override bool OnHover(HoverEvent e) + public override void PlayHoverSample() { - bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= HoverDebounceTime; - - if (enoughTimePassedSinceLastPlayback) - { - sampleHover?.Play(); - lastPlaybackTime.Value = Time.Current; - } - - return base.OnHover(e); + sampleHover.Frequency.Value = 0.96 + RNG.NextDouble(0.08); + sampleHover.Play(); } } @@ -68,6 +50,9 @@ namespace osu.Game.Graphics.UserInterface Normal, [Description("-softer")] - Soft + Soft, + + [Description("-toolbar")] + Toolbar } } diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs index 82b09e0821..18d8b880ea 100644 --- a/osu.Game/Graphics/UserInterface/Nub.cs +++ b/osu.Game/Graphics/UserInterface/Nub.cs @@ -42,13 +42,7 @@ namespace osu.Game.Graphics.UserInterface }, }; - Current.ValueChanged += filled => - { - if (filled.NewValue) - fill.FadeIn(200, Easing.OutQuint); - else - fill.FadeTo(0.01f, 200, Easing.OutQuint); //todo: remove once we figure why containers aren't drawing at all times - }; + Current.ValueChanged += filled => fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); } [BackgroundDependencyLoader] diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 9cf8f02024..a22c837080 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -21,9 +22,9 @@ namespace osu.Game.Graphics.UserInterface /// public class OsuButton : Button { - public string Text + public LocalisableString Text { - get => SpriteText?.Text; + get => SpriteText?.Text ?? default; set { if (SpriteText != null) diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index 6593531099..5f2d884cd7 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; @@ -18,6 +19,11 @@ namespace osu.Game.Graphics.UserInterface public Color4 UncheckedColor { get; set; } = Color4.White; public int FadeDuration { get; set; } + /// + /// Whether to play sounds when the state changes as a result of user interaction. + /// + protected virtual bool PlaySoundsOnUserChange => true; + public string LabelText { set @@ -40,10 +46,10 @@ namespace osu.Game.Graphics.UserInterface protected readonly Nub Nub; private readonly OsuTextFlowContainer labelText; - private SampleChannel sampleChecked; - private SampleChannel sampleUnchecked; + private Sample sampleChecked; + private Sample sampleUnchecked; - public OsuCheckbox() + public OsuCheckbox(bool nubOnRight = true) { AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; @@ -52,26 +58,42 @@ namespace osu.Game.Graphics.UserInterface Children = new Drawable[] { - labelText = new OsuTextFlowContainer + labelText = new OsuTextFlowContainer(ApplyLabelParameters) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding } }, - Nub = new Nub - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding { Right = nub_padding }, - }, - new HoverClickSounds() + Nub = new Nub(), + new HoverSounds() }; + if (nubOnRight) + { + Nub.Anchor = Anchor.CentreRight; + Nub.Origin = Anchor.CentreRight; + Nub.Margin = new MarginPadding { Right = nub_padding }; + labelText.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 }; + } + else + { + Nub.Anchor = Anchor.CentreLeft; + Nub.Origin = Anchor.CentreLeft; + Nub.Margin = new MarginPadding { Left = nub_padding }; + labelText.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 }; + } + Nub.Current.BindTo(Current); Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1; } + /// + /// A function which can be overridden to change the parameters of the label's text. + /// + protected virtual void ApplyLabelParameters(SpriteText text) + { + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -96,10 +118,14 @@ namespace osu.Game.Graphics.UserInterface protected override void OnUserChange(bool value) { base.OnUserChange(value); - if (value) - sampleChecked?.Play(); - else - sampleUnchecked?.Play(); + + if (PlaySoundsOnUserChange) + { + if (value) + sampleChecked?.Play(); + else + sampleUnchecked?.Play(); + } } } } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index cc76c12975..15fb00ccb0 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; @@ -168,7 +169,7 @@ namespace osu.Game.Graphics.UserInterface protected new class Content : FillFlowContainer, IHasText { - public string Text + public LocalisableString Text { get => Label.Text; set => Label.Text = value; @@ -215,7 +216,7 @@ namespace osu.Game.Graphics.UserInterface { protected readonly SpriteText Text; - protected override string Label + protected override LocalisableString Label { get => Text.Text; set => Text.Text = value; diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index d0356e77c7..f58962f8e1 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -25,7 +25,7 @@ namespace osu.Game.Graphics.UserInterface /// private const int max_decimal_digits = 5; - private SampleChannel sample; + private Sample sample; private double lastSampleTime; private T lastSampleValue; @@ -155,16 +155,15 @@ namespace osu.Game.Graphics.UserInterface return; lastSampleValue = value; - lastSampleTime = Clock.CurrentTime; - sample.Frequency.Value = 1 + NormalizedValue * 0.2f; + var channel = sample.Play(); + + channel.Frequency.Value = 1 + NormalizedValue * 0.2f; if (NormalizedValue == 0) - sample.Frequency.Value -= 0.4f; + channel.Frequency.Value -= 0.4f; else if (NormalizedValue == 1) - sample.Frequency.Value += 0.4f; - - sample.Play(); + channel.Frequency.Value += 0.4f; } private void updateTooltipText(T value) diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index bdc95ee048..b66a4a58ce 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics.Sprites; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -35,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface } } - public string Text + public LocalisableString Text { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 1ec4dfc91a..75af9efc38 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -23,11 +23,11 @@ namespace osu.Game.Graphics.UserInterface { public class OsuTextBox : BasicTextBox { - private readonly SampleChannel[] textAddedSamples = new SampleChannel[4]; - private SampleChannel capsTextAddedSample; - private SampleChannel textRemovedSample; - private SampleChannel textCommittedSample; - private SampleChannel caretMovedSample; + private readonly Sample[] textAddedSamples = new Sample[4]; + private Sample capsTextAddedSample; + private Sample textRemovedSample; + private Sample textCommittedSample; + private Sample caretMovedSample; /// /// Whether to allow playing a different samples based on the type of character. diff --git a/osu.Game/Graphics/UserInterface/ProgressBar.cs b/osu.Game/Graphics/UserInterface/ProgressBar.cs index d271cd121c..50367e600e 100644 --- a/osu.Game/Graphics/UserInterface/ProgressBar.cs +++ b/osu.Game/Graphics/UserInterface/ProgressBar.cs @@ -40,8 +40,19 @@ namespace osu.Game.Graphics.UserInterface set => CurrentNumber.Value = value; } - public ProgressBar() + private readonly bool allowSeek; + + public override bool HandlePositionalInput => allowSeek; + public override bool HandleNonPositionalInput => allowSeek; + + /// + /// Construct a new progress bar. + /// + /// Whether the user should be allowed to click/drag to adjust the value. + public ProgressBar(bool allowSeek) { + this.allowSeek = allowSeek; + CurrentNumber.MinValue = 0; CurrentNumber.MaxValue = 1; RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs index 924c7913f3..615895074c 100644 --- a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osuTK; using System.Collections.Generic; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -18,7 +19,7 @@ namespace osu.Game.Graphics.UserInterface { private const int duration = 200; - public string Text + public LocalisableString Text { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Graphics/UserInterface/TriangleButton.cs b/osu.Game/Graphics/UserInterface/TriangleButton.cs index 5baf794227..003a81f562 100644 --- a/osu.Game/Graphics/UserInterface/TriangleButton.cs +++ b/osu.Game/Graphics/UserInterface/TriangleButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Graphics.UserInterface }); } - public virtual IEnumerable FilterTerms => new[] { Text }; + public virtual IEnumerable FilterTerms => new[] { Text.ToString() }; public bool MatchingFilter { diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 120149d8c1..8f03c7073c 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; using osu.Framework.Audio.Track; using System; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; @@ -56,15 +57,15 @@ namespace osu.Game.Graphics.UserInterface set { base.Origin = value; - c1.Origin = c1.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; - c2.Origin = c2.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; + c1.Origin = c1.Anchor = value.HasFlagFast(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; + c2.Origin = c2.Anchor = value.HasFlagFast(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; - X = value.HasFlag(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; + X = value.HasFlagFast(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; Remove(c1); Remove(c2); - c1.Depth = value.HasFlag(Anchor.x2) ? 0 : 1; - c2.Depth = value.HasFlag(Anchor.x2) ? 1 : 0; + c1.Depth = value.HasFlagFast(Anchor.x2) ? 0 : 1; + c2.Depth = value.HasFlagFast(Anchor.x2) ? 1 : 0; Add(c1); Add(c2); } diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 4aeda74be8..266eb11319 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 @@ -53,6 +54,14 @@ namespace osu.Game.Graphics.UserInterfaceV2 CornerRadius = CORNER_RADIUS, }; + public override bool AcceptsFocus => true; + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + GetContainingInputManager().ChangeFocus(Component); + } + protected override OsuTextBox CreateComponent() => CreateTextBox().With(t => { t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText); diff --git a/osu.Game/IO/Archives/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs index f74574e60c..679ab40402 100644 --- a/osu.Game/IO/Archives/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -41,7 +41,7 @@ namespace osu.Game.IO.Archives return null; byte[] buffer = new byte[input.Length]; - await input.ReadAsync(buffer); + await input.ReadAsync(buffer).ConfigureAwait(false); return buffer; } } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index f7abd2a6f1..1ed9a80a80 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -63,7 +63,7 @@ namespace osu.Game.IO /// public void ResetCustomStoragePath() { - storageConfig.Set(StorageConfig.FullPath, string.Empty); + storageConfig.SetValue(StorageConfig.FullPath, string.Empty); storageConfig.Save(); ChangeTargetStorage(defaultStorage); @@ -108,7 +108,7 @@ namespace osu.Game.IO public override void Migrate(Storage newStorage) { base.Migrate(newStorage); - storageConfig.Set(StorageConfig.FullPath, newStorage.GetFullPath(".")); + storageConfig.SetValue(StorageConfig.FullPath, newStorage.GetFullPath(".")); storageConfig.Save(); } } diff --git a/osu.Game/IO/StableStorage.cs b/osu.Game/IO/StableStorage.cs new file mode 100644 index 0000000000..d4b0d300ff --- /dev/null +++ b/osu.Game/IO/StableStorage.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A storage pointing to an osu-stable installation. + /// Provides methods for handling installations with a custom Song folder location. + /// + public class StableStorage : DesktopStorage + { + private const string stable_default_songs_path = "Songs"; + + private readonly DesktopGameHost host; + private readonly Lazy songsPath; + + public StableStorage(string path, DesktopGameHost host) + : base(path, host) + { + this.host = host; + + songsPath = new Lazy(locateSongsDirectory); + } + + /// + /// Returns a pointing to the osu-stable Songs directory. + /// + public Storage GetSongStorage() => new DesktopStorage(songsPath.Value, host); + + private string locateSongsDirectory() + { + var configFile = GetFiles(".", $"osu!.{Environment.UserName}.cfg").SingleOrDefault(); + + if (configFile != null) + { + using (var stream = GetStream(configFile)) + using (var textReader = new StreamReader(stream)) + { + string line; + + while ((line = textReader.ReadLine()) != null) + { + if (!line.StartsWith("BeatmapDirectory", StringComparison.OrdinalIgnoreCase)) continue; + + var customDirectory = line.Split('=').LastOrDefault()?.Trim(); + if (customDirectory != null && Path.IsPathFullyQualified(customDirectory)) + return customDirectory; + + break; + } + } + } + + return GetFullPath(stable_default_songs_path); + } + } +} diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index 029908ec9d..d9d0e4c0ea 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -33,12 +33,12 @@ namespace osu.Game.IPC if (importer == null) { // we want to contact a remote osu! to handle the import. - await SendMessageAsync(new ArchiveImportMessage { Path = path }); + await SendMessageAsync(new ArchiveImportMessage { Path = path }).ConfigureAwait(false); return; } if (importer.HandledExtensions.Contains(Path.GetExtension(path)?.ToLowerInvariant())) - await importer.Import(path); + await importer.Import(path).ConfigureAwait(false); } } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 2aaea22155..944525c119 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -25,6 +25,8 @@ namespace osu.Game.Online.API { private readonly OsuConfigManager config; + private readonly string versionHash; + private readonly OAuth authentication; private readonly Queue queue = new Queue(); @@ -56,9 +58,10 @@ namespace osu.Game.Online.API private readonly Logger log; - public APIAccess(OsuConfigManager config, EndpointConfiguration endpointConfiguration) + public APIAccess(OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) { this.config = config; + this.versionHash = versionHash; APIEndpointUrl = endpointConfiguration.APIEndpointUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; @@ -86,7 +89,7 @@ namespace osu.Game.Online.API thread.Start(); } - private void onTokenChanged(ValueChangedEvent e) => config.Set(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); + private void onTokenChanged(ValueChangedEvent e) => config.SetValue(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); internal new void Schedule(Action action) => base.Schedule(action); @@ -131,7 +134,7 @@ namespace osu.Game.Online.API state.Value = APIState.Connecting; // save the username at this point, if the user requested for it to be. - config.Set(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); + config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); if (!authentication.HasValidAccessToken && !authentication.AuthenticateWithLogin(ProvidedUsername, password)) { @@ -243,6 +246,9 @@ namespace osu.Game.Online.API this.password = password; } + public IHubClientConnector GetHubConnector(string clientName, string endpoint) => + new HubClientConnector(clientName, endpoint, this, versionHash); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -368,7 +374,13 @@ namespace osu.Game.Online.API public void Queue(APIRequest request) { - lock (queue) queue.Enqueue(request); + lock (queue) + { + if (state.Value == APIState.Offline) + return; + + queue.Enqueue(request); + } } private void flushQueue(bool failOldRequests = true) @@ -389,8 +401,6 @@ namespace osu.Game.Online.API public void Logout() { - flushQueue(); - password = null; authentication.Clear(); @@ -402,6 +412,7 @@ namespace osu.Game.Online.API }); state.Value = APIState.Offline; + flushQueue(); } private static User createGuestUser() => new GuestUser(); diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs index 940b9b4803..62e22d8f88 100644 --- a/osu.Game/Online/API/APIDownloadRequest.cs +++ b/osu.Game/Online/API/APIDownloadRequest.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.IO; using osu.Framework.IO.Network; @@ -28,13 +29,19 @@ namespace osu.Game.Online.API private void request_Progress(long current, long total) => API.Schedule(() => Progressed?.Invoke(current, total)); - protected APIDownloadRequest() + protected void TriggerSuccess(string filename) { - base.Success += onSuccess; + if (this.filename != null) + throw new InvalidOperationException("Attempted to trigger success more than once"); + + this.filename = filename; + + TriggerSuccess(); } - private void onSuccess() + internal override void TriggerSuccess() { + base.TriggerSuccess(); Success?.Invoke(filename); } diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index c8b76b9685..bff08b0515 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using Humanizer; +using MessagePack; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Configuration; @@ -13,16 +14,21 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Online.API { + [MessagePackObject] public class APIMod : IMod { [JsonProperty("acronym")] + [Key(0)] public string Acronym { get; set; } [JsonProperty("settings")] + [Key(1)] + [MessagePackFormatter(typeof(ModSettingsDictionaryFormatter))] public Dictionary Settings { get; set; } = new Dictionary(); [JsonConstructor] - private APIMod() + [SerializationConstructor] + public APIMod() { } diff --git a/osu.Game/Online/API/ArchiveDownloadRequest.cs b/osu.Game/Online/API/ArchiveDownloadRequest.cs index f1966aeb2b..0bf238109e 100644 --- a/osu.Game/Online/API/ArchiveDownloadRequest.cs +++ b/osu.Game/Online/API/ArchiveDownloadRequest.cs @@ -10,7 +10,7 @@ namespace osu.Game.Online.API { public readonly TModel Model; - public float Progress; + public float Progress { get; private set; } public event Action DownloadProgressed; @@ -18,7 +18,13 @@ namespace osu.Game.Online.API { Model = model; - Progressed += (current, total) => DownloadProgressed?.Invoke(Progress = (float)current / total); + Progressed += (current, total) => SetProgress((float)current / total); + } + + protected void SetProgress(float progress) + { + Progress = progress; + DownloadProgressed?.Invoke(progress); } } } diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 3e996ac97f..943b52db88 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -83,6 +83,8 @@ namespace osu.Game.Online.API state.Value = APIState.Offline; } + public IHubClientConnector GetHubConnector(string clientName, string endpoint) => null; + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 1951dfaf40..3a77b9cfee 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.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 System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Users; @@ -95,6 +97,13 @@ namespace osu.Game.Online.API /// void Logout(); + /// + /// Constructs a new . May be null if not supported. + /// + /// The name of the client this connector connects for, used for logging. + /// The endpoint to the hub. + IHubClientConnector? GetHubConnector(string clientName, string endpoint); + /// /// Create a new user account. This is a blocking operation. /// @@ -102,6 +111,6 @@ namespace osu.Game.Online.API /// The username to create the account with. /// The password to create the account with. /// Any errors encoutnered during account creation. - RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password); + RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password); } } diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs new file mode 100644 index 0000000000..dd854acc32 --- /dev/null +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using MessagePack; +using MessagePack.Formatters; +using osu.Framework.Bindables; + +namespace osu.Game.Online.API +{ + public class ModSettingsDictionaryFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, Dictionary value, MessagePackSerializerOptions options) + { + var primitiveFormatter = PrimitiveObjectFormatter.Instance; + + writer.WriteArrayHeader(value.Count); + + foreach (var kvp in value) + { + var stringBytes = new ReadOnlySequence(Encoding.UTF8.GetBytes(kvp.Key)); + writer.WriteString(in stringBytes); + + switch (kvp.Value) + { + case Bindable d: + primitiveFormatter.Serialize(ref writer, d.Value, options); + break; + + case Bindable i: + primitiveFormatter.Serialize(ref writer, i.Value, options); + break; + + case Bindable f: + primitiveFormatter.Serialize(ref writer, f.Value, options); + break; + + case Bindable b: + primitiveFormatter.Serialize(ref writer, b.Value, options); + break; + + case IBindable u: + // A mod with unknown (e.g. enum) generic type. + var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); + Debug.Assert(valueMethod != null); + primitiveFormatter.Serialize(ref writer, valueMethod.GetValue(u), options); + break; + + default: + // fall back for non-bindable cases. + primitiveFormatter.Serialize(ref writer, kvp.Value, options); + break; + } + } + } + + public Dictionary Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var output = new Dictionary(); + + int itemCount = reader.ReadArrayHeader(); + + for (int i = 0; i < itemCount; i++) + { + output[reader.ReadString()] = + PrimitiveObjectFormatter.Instance.Deserialize(ref reader, options); + } + + return output; + } + } +} diff --git a/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs index 707c59436d..e8871bef05 100644 --- a/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs +++ b/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.IO.Network; using osu.Game.Beatmaps; namespace osu.Game.Online.API.Requests @@ -15,6 +16,13 @@ namespace osu.Game.Online.API.Requests this.noVideo = noVideo; } + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Timeout = 60000; + return req; + } + protected override string Target => $@"beatmapsets/{Model.OnlineBeatmapSetID}/download{(noVideo ? "?noVideo=1" : "")}"; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index bd1800e9f7..45d9c9405f 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -81,7 +81,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"beatmaps")] private IEnumerable beatmaps { get; set; } - public BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) + public virtual BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) { var beatmapSet = new BeatmapSetInfo { diff --git a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs index bcc8721400..172fa3a583 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs @@ -22,7 +22,7 @@ namespace osu.Game.Online.API.Requests.Responses public double? PP { get; set; } [JsonProperty(@"room_id")] - public int RoomID { get; set; } + public long RoomID { get; set; } [JsonProperty("total_score")] public long TotalScore { get; set; } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 62ae507419..a980f4c54b 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -152,7 +152,7 @@ namespace osu.Game.Online.Chat createNewPrivateMessageRequest.Failure += exception => { - Logger.Error(exception, "Posting message failed."); + handlePostException(exception); target.ReplaceMessage(message, null); dequeueAndRun(); }; @@ -171,7 +171,7 @@ namespace osu.Game.Online.Chat req.Failure += exception => { - Logger.Error(exception, "Posting message failed."); + handlePostException(exception); target.ReplaceMessage(message, null); dequeueAndRun(); }; @@ -184,6 +184,14 @@ namespace osu.Game.Online.Chat dequeueAndRun(); } + private static void handlePostException(Exception exception) + { + if (exception is APIException apiException) + Logger.Log(apiException.Message, level: LogLevel.Important); + else + Logger.Error(exception, "Posting message failed."); + } + /// /// Posts a command locally. Commands like /help will result in a help message written in the current channel. /// @@ -339,7 +347,7 @@ namespace osu.Game.Online.Chat } /// - /// Joins a channel if it has not already been joined. + /// Joins a channel if it has not already been joined. Must be called from the update thread. /// /// The channel to join. /// The joined channel. Note that this may not match the parameter channel as it is a backed object. @@ -399,7 +407,11 @@ namespace osu.Game.Online.Chat return channel; } - public void LeaveChannel(Channel channel) + /// + /// Leave the specified channel. Can be called from any thread. + /// + /// The channel to leave. + public void LeaveChannel(Channel channel) => Schedule(() => { if (channel == null) return; @@ -413,7 +425,7 @@ namespace osu.Game.Online.Chat api.Queue(new LeaveChannelRequest(channel)); channel.Joined.Value = false; } - } + }); private long lastMessageId; diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index d2a117876d..b80720a0aa 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -49,6 +49,18 @@ namespace osu.Game.Online.Chat // Unicode emojis private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])"); + /// + /// The root URL for the website, used for chat link matching. + /// + public static string WebsiteRootUrl + { + set => websiteRootUrl = value + .Trim('/') // trim potential trailing slash/ + .Split('/').Last(); // only keep domain name, ignoring protocol. + } + + private static string websiteRootUrl = "osu.ppy.sh"; + private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null, char[] escapeChars = null) { int captureOffset = 0; @@ -119,22 +131,42 @@ 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] == "osu.ppy.sh") + if (args.Length > 3 && args[1].EndsWith(websiteRootUrl, StringComparison.OrdinalIgnoreCase)) { + var mainArg = args[3]; + switch (args[2]) { + // old site only case "b": case "beatmaps": - return new LinkDetails(LinkAction.OpenBeatmap, args[3]); + { + string trimmed = mainArg.Split('?').First(); + if (int.TryParse(trimmed, out var id)) + return new LinkDetails(LinkAction.OpenBeatmap, id.ToString()); + + break; + } case "s": case "beatmapsets": case "d": - return new LinkDetails(LinkAction.OpenBeatmapSet, args[3]); + { + if (args.Length > 4 && int.TryParse(args[4], out var id)) + // https://osu.ppy.sh/beatmapsets/1154158#osu/2768184 + return new LinkDetails(LinkAction.OpenBeatmap, id.ToString()); + + // https://osu.ppy.sh/beatmapsets/1154158#whatever + string trimmed = mainArg.Split('#').First(); + if (int.TryParse(trimmed, out id)) + return new LinkDetails(LinkAction.OpenBeatmapSet, id.ToString()); + + break; + } case "u": case "users": - return new LinkDetails(LinkAction.OpenUserProfile, args[3]); + return new LinkDetails(LinkAction.OpenUserProfile, mainArg); } } @@ -183,10 +215,9 @@ namespace osu.Game.Online.Chat case "osump": return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); - - default: - return new LinkDetails(LinkAction.External, null); } + + return new LinkDetails(LinkAction.External, null); } private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3) @@ -259,8 +290,9 @@ namespace osu.Game.Online.Chat public class LinkDetails { - public LinkAction Action; - public string Argument; + public readonly LinkAction Action; + + public readonly string Argument; public LinkDetails(LinkAction action, string argument) { diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index 7a64c9002d..52042c266b 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -20,7 +21,7 @@ namespace osu.Game.Online protected readonly Bindable Model = new Bindable(); [Resolved(CanBeNull = true)] - private TModelManager manager { get; set; } + protected TModelManager Manager { get; private set; } /// /// Holds the current download state of the , whether is has already been downloaded, is in progress, or is not downloaded. @@ -46,25 +47,41 @@ namespace osu.Game.Online { if (modelInfo.NewValue == null) attachDownload(null); - else if (manager?.IsAvailableLocally(modelInfo.NewValue) == true) + else if (IsModelAvailableLocally()) State.Value = DownloadState.LocallyAvailable; else - attachDownload(manager?.GetExistingDownload(modelInfo.NewValue)); + attachDownload(Manager?.GetExistingDownload(modelInfo.NewValue)); }, true); - if (manager == null) + if (Manager == null) return; - managerDownloadBegan = manager.DownloadBegan.GetBoundCopy(); + managerDownloadBegan = Manager.DownloadBegan.GetBoundCopy(); managerDownloadBegan.BindValueChanged(downloadBegan); - managerDownloadFailed = manager.DownloadFailed.GetBoundCopy(); + managerDownloadFailed = Manager.DownloadFailed.GetBoundCopy(); managerDownloadFailed.BindValueChanged(downloadFailed); - managedUpdated = manager.ItemUpdated.GetBoundCopy(); + managedUpdated = Manager.ItemUpdated.GetBoundCopy(); managedUpdated.BindValueChanged(itemUpdated); - managerRemoved = manager.ItemRemoved.GetBoundCopy(); + managerRemoved = Manager.ItemRemoved.GetBoundCopy(); managerRemoved.BindValueChanged(itemRemoved); } + /// + /// Checks that a database model matches the one expected to be downloaded. + /// + /// + /// For online play, this could be used to check that the databased model matches the online beatmap. + /// + /// The model in database. + protected virtual bool VerifyDatabasedModel([NotNull] TModel databasedModel) => true; + + /// + /// Whether the given model is available in the database. + /// By default, this calls , + /// but can be overriden to add additional checks for verifying the model in database. + /// + protected virtual bool IsModelAvailableLocally() => Manager?.IsAvailableLocally(Model.Value) == true; + private void downloadBegan(ValueChangedEvent>> weakRequest) { if (weakRequest.NewValue.TryGetTarget(out var request)) @@ -134,23 +151,35 @@ namespace osu.Game.Online private void itemUpdated(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) - setDownloadStateFromManager(item, DownloadState.LocallyAvailable); + { + Schedule(() => + { + if (!item.Equals(Model.Value)) + return; + + if (!VerifyDatabasedModel(item)) + { + State.Value = DownloadState.NotDownloaded; + return; + } + + State.Value = DownloadState.LocallyAvailable; + }); + } } private void itemRemoved(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) - setDownloadStateFromManager(item, DownloadState.NotDownloaded); + { + Schedule(() => + { + if (item.Equals(Model.Value)) + State.Value = DownloadState.NotDownloaded; + }); + } } - private void setDownloadStateFromManager(TModel s, DownloadState state) => Schedule(() => - { - if (!s.Equals(Model.Value)) - return; - - State.Value = state; - }); - #region Disposal protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs new file mode 100644 index 0000000000..3839762e46 --- /dev/null +++ b/osu.Game/Online/HubClientConnector.cs @@ -0,0 +1,208 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using osu.Framework; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Online.API; + +namespace osu.Game.Online +{ + public class HubClientConnector : IHubClientConnector + { + /// + /// Invoked whenever a new hub connection is built, to configure it before it's started. + /// + public Action? ConfigureConnection { get; set; } + + private readonly string clientName; + private readonly string endpoint; + private readonly string versionHash; + private readonly IAPIProvider api; + + /// + /// The current connection opened by this connector. + /// + public HubConnection? CurrentConnection { get; private set; } + + /// + /// Whether this is connected to the hub, use to access the connection, if this is true. + /// + public IBindable IsConnected => isConnected; + + private readonly Bindable isConnected = new Bindable(); + private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); + private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); + + private readonly IBindable apiState = new Bindable(); + + /// + /// Constructs a new . + /// + /// The name of the client this connector connects for, used for logging. + /// The endpoint to the hub. + /// An API provider used to react to connection state changes. + /// The hash representing the current game version, used for verification purposes. + public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash) + { + this.clientName = clientName; + this.endpoint = endpoint; + this.api = api; + this.versionHash = versionHash; + + apiState.BindTo(api.State); + apiState.BindValueChanged(state => + { + switch (state.NewValue) + { + case APIState.Failing: + case APIState.Offline: + Task.Run(() => disconnect(true)); + break; + + case APIState.Online: + Task.Run(connect); + break; + } + }, true); + } + + private async Task connect() + { + cancelExistingConnect(); + + if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) + throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); + + try + { + while (apiState.Value == APIState.Online) + { + // ensure any previous connection was disposed. + // this will also create a new cancellation token source. + await disconnect(false).ConfigureAwait(false); + + // this token will be valid for the scope of this connection. + // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. + var cancellationToken = connectCancelSource.Token; + + cancellationToken.ThrowIfCancellationRequested(); + + Logger.Log($"{clientName} connecting...", LoggingTarget.Network); + + try + { + // importantly, rebuild the connection each attempt to get an updated access token. + CurrentConnection = buildConnection(cancellationToken); + + await CurrentConnection.StartAsync(cancellationToken).ConfigureAwait(false); + + Logger.Log($"{clientName} connected!", LoggingTarget.Network); + isConnected.Value = true; + return; + } + catch (OperationCanceledException) + { + //connection process was cancelled. + throw; + } + catch (Exception e) + { + Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network); + + // retry on any failure. + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + connectionLock.Release(); + } + } + + private HubConnection buildConnection(CancellationToken cancellationToken) + { + var builder = new HubConnectionBuilder() + .WithUrl(endpoint, options => + { + options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); + options.Headers.Add("OsuVersionHash", versionHash); + }); + + if (RuntimeInfo.SupportsJIT) + builder.AddMessagePackProtocol(); + else + { + // eventually we will precompile resolvers for messagepack, but this isn't working currently + // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308. + builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); + } + + var newConnection = builder.Build(); + + ConfigureConnection?.Invoke(newConnection); + + newConnection.Closed += ex => onConnectionClosed(ex, cancellationToken); + return newConnection; + } + + private Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) + { + isConnected.Value = false; + + Logger.Log(ex != null ? $"{clientName} lost connection: {ex}" : $"{clientName} disconnected", LoggingTarget.Network); + + // make sure a disconnect wasn't triggered (and this is still the active connection). + if (!cancellationToken.IsCancellationRequested) + Task.Run(connect, default); + + return Task.CompletedTask; + } + + private async Task disconnect(bool takeLock) + { + cancelExistingConnect(); + + if (takeLock) + { + if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) + throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); + } + + try + { + if (CurrentConnection != null) + await CurrentConnection.DisposeAsync().ConfigureAwait(false); + } + finally + { + CurrentConnection = null; + if (takeLock) + connectionLock.Release(); + } + } + + private void cancelExistingConnect() + { + connectCancelSource.Cancel(); + connectCancelSource = new CancellationTokenSource(); + } + + public override string ToString() => $"Connector for {clientName} ({(IsConnected.Value ? "connected" : "not connected")}"; + + public void Dispose() + { + apiState.UnbindAll(); + cancelExistingConnect(); + } + } +} diff --git a/osu.Game/Online/IHubClientConnector.cs b/osu.Game/Online/IHubClientConnector.cs new file mode 100644 index 0000000000..d2ceb1f030 --- /dev/null +++ b/osu.Game/Online/IHubClientConnector.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Bindables; +using osu.Game.Online.API; + +namespace osu.Game.Online +{ + /// + /// A component that manages the life cycle of a connection to a SignalR Hub. + /// Should generally be retrieved from an . + /// + public interface IHubClientConnector : IDisposable + { + /// + /// The current connection opened by this connector. + /// + HubConnection? CurrentConnection { get; } + + /// + /// Whether this is connected to the hub, use to access the connection, if this is true. + /// + IBindable IsConnected { get; } + + /// + /// Invoked whenever a new hub connection is built, to configure it before it's started. + /// + public Action? ConfigureConnection { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index 19dd473230..6d7b9d24d6 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Threading.Tasks; +using osu.Game.Online.API; using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer @@ -55,6 +57,13 @@ namespace osu.Game.Online.Multiplayer /// The new beatmap availability state of the user. Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability); + /// + /// Signals that a user in this room changed their local mods. + /// + /// The ID of the user whose mods have changed. + /// The user's new local mods. + Task UserModsChanged(int userId, IEnumerable mods); + /// /// Signals that a match is to be started. This will *only* be sent to clients which are to begin loading at this point. /// diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 09816974a7..3527ce6314 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Threading.Tasks; +using osu.Game.Online.API; using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer @@ -47,6 +49,12 @@ namespace osu.Game.Online.Multiplayer /// The proposed new beatmap availability state. Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + /// + /// Change the local user's mods in the currently joined room. + /// + /// The proposed new mods, excluding any required by the room itself. + Task ChangeUserMods(IEnumerable newMods); + /// /// As the host of a room, start the match. /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 50dc8f661c..4529dfd0a7 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -3,156 +3,81 @@ #nullable enable -using System; -using System.Diagnostics; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Logging; +using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer { public class MultiplayerClient : StatefulMultiplayerClient { - public override IBindable IsConnected => isConnected; - - private readonly Bindable isConnected = new Bindable(); - private readonly IBindable apiState = new Bindable(); - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - private HubConnection? connection; - private readonly string endpoint; + private IHubClientConnector? connector; + + public override IBindable IsConnected { get; } = new BindableBool(); + + private HubConnection? connection => connector?.CurrentConnection; + public MultiplayerClient(EndpointConfiguration endpoints) { endpoint = endpoints.MultiplayerEndpointUrl; } [BackgroundDependencyLoader] - private void load() + private void load(IAPIProvider api) { - apiState.BindTo(api.State); - apiState.BindValueChanged(apiStateChanged, true); - } + connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint); - private void apiStateChanged(ValueChangedEvent state) - { - switch (state.NewValue) + if (connector != null) { - case APIState.Failing: - case APIState.Offline: - connection?.StopAsync(); - connection = null; - break; - - case APIState.Online: - Task.Run(Connect); - break; - } - } - - protected virtual async Task Connect() - { - if (connection != null) - return; - - connection = new HubConnectionBuilder() - .WithUrl(endpoint, options => - { - options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); - }) - .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }) - .Build(); - - // this is kind of SILLY - // https://github.com/dotnet/aspnetcore/issues/15198 - connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); - connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); - connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); - connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); - connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); - connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); - connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); - connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); - connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); - - connection.Closed += async ex => - { - isConnected.Value = false; - - Logger.Log(ex != null - ? $"Multiplayer client lost connection: {ex}" - : "Multiplayer client disconnected", LoggingTarget.Network); - - if (connection != null) - await tryUntilConnected(); - }; - - await tryUntilConnected(); - - async Task tryUntilConnected() - { - Logger.Log("Multiplayer client connecting...", LoggingTarget.Network); - - while (api.State.Value == APIState.Online) + connector.ConfigureConnection = connection => { - try - { - Debug.Assert(connection != null); + // this is kind of SILLY + // https://github.com/dotnet/aspnetcore/issues/15198 + connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); + connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); + connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); + connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); + connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); + connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); + connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); + connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); + connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); + connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); + }; - // reconnect on any failure - await connection.StartAsync(); - Logger.Log("Multiplayer client connected!", LoggingTarget.Network); - - // Success. - isConnected.Value = true; - break; - } - catch (Exception e) - { - Logger.Log($"Multiplayer client connection error: {e}", LoggingTarget.Network); - await Task.Delay(5000); - } - } + IsConnected.BindTo(connector.IsConnected); } } protected override Task JoinRoom(long roomId) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.FromCanceled(new CancellationToken(true)); return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); } - public override async Task LeaveRoom() + protected override Task LeaveRoomInternal() { - if (!isConnected.Value) - { - // even if not connected, make sure the local room state can be cleaned up. - await base.LeaveRoom(); - return; - } + if (!IsConnected.Value) + return Task.FromCanceled(new CancellationToken(true)); - if (Room == null) - return; - - await base.LeaveRoom(); - await connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); + return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); } public override Task TransferHost(int userId) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); @@ -160,7 +85,7 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeSettings(MultiplayerRoomSettings settings) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings); @@ -168,7 +93,7 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeState(MultiplayerUserState newState) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); @@ -176,18 +101,55 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); } + public override Task ChangeUserMods(IEnumerable newMods) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); + } + public override Task StartMatch() { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); } + + protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); + + req.Success += res => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(); + return; + } + + tcs.SetResult(res.ToBeatmapSet(Rulesets)); + }; + + req.Failure += e => tcs.SetException(e); + + API.Queue(req); + + return tcs.Task; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + connector?.Dispose(); + } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index 12fcf25ace..c5fa6253ed 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using MessagePack; using Newtonsoft.Json; namespace osu.Game.Online.Multiplayer @@ -13,35 +14,42 @@ namespace osu.Game.Online.Multiplayer /// A multiplayer room. /// [Serializable] + [MessagePackObject] public class MultiplayerRoom { /// /// The ID of the room, used for database persistence. /// + [Key(0)] public readonly long RoomID; /// /// The current state of the room (ie. whether it is in progress or otherwise). /// + [Key(1)] public MultiplayerRoomState State { get; set; } /// /// All currently enforced game settings for this room. /// + [Key(2)] public MultiplayerRoomSettings Settings { get; set; } = new MultiplayerRoomSettings(); /// /// All users currently in this room. /// + [Key(3)] public List Users { get; set; } = new List(); /// /// The host of this room, in control of changing room settings. /// + [Key(4)] public MultiplayerRoomUser? Host { get; set; } [JsonConstructor] - public MultiplayerRoom(in long roomId) + [SerializationConstructor] + public MultiplayerRoom(long roomId) { RoomID = roomId; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index 857b38ea60..7d6c76bc2f 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -7,31 +7,52 @@ using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using MessagePack; using osu.Game.Online.API; namespace osu.Game.Online.Multiplayer { [Serializable] + [MessagePackObject] public class MultiplayerRoomSettings : IEquatable { + [Key(0)] public int BeatmapID { get; set; } + [Key(1)] public int RulesetID { get; set; } + [Key(2)] public string BeatmapChecksum { get; set; } = string.Empty; + [Key(3)] public string Name { get; set; } = "Unnamed room"; [NotNull] - public IEnumerable Mods { get; set; } = Enumerable.Empty(); + [Key(4)] + public IEnumerable RequiredMods { get; set; } = Enumerable.Empty(); + + [NotNull] + [Key(5)] + public IEnumerable AllowedMods { get; set; } = Enumerable.Empty(); + + [Key(6)] + public long PlaylistItemId { get; set; } public bool Equals(MultiplayerRoomSettings other) => BeatmapID == other.BeatmapID && BeatmapChecksum == other.BeatmapChecksum - && Mods.SequenceEqual(other.Mods) + && RequiredMods.SequenceEqual(other.RequiredMods) + && AllowedMods.SequenceEqual(other.AllowedMods) && RulesetID == other.RulesetID - && Name.Equals(other.Name, StringComparison.Ordinal); + && Name.Equals(other.Name, StringComparison.Ordinal) + && PlaylistItemId == other.PlaylistItemId; - public override string ToString() => $"Name:{Name} Beatmap:{BeatmapID} ({BeatmapChecksum}) Mods:{string.Join(',', Mods)} Ruleset:{RulesetID}"; + public override string ToString() => $"Name:{Name}" + + $" Beatmap:{BeatmapID} ({BeatmapChecksum})" + + $" RequiredMods:{string.Join(',', RequiredMods)}" + + $" AllowedMods:{string.Join(',', AllowedMods)}" + + $" Ruleset:{RulesetID}" + + $" Item:{PlaylistItemId}"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index 2590acbc81..c654127b94 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -4,28 +4,45 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using MessagePack; using Newtonsoft.Json; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Users; namespace osu.Game.Online.Multiplayer { [Serializable] + [MessagePackObject] public class MultiplayerRoomUser : IEquatable { + [Key(0)] public readonly int UserID; + [Key(1)] public MultiplayerUserState State { get; set; } = MultiplayerUserState.Idle; /// /// The availability state of the current beatmap. /// + [Key(2)] public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.LocallyAvailable(); + /// + /// Any mods applicable only to the local user. + /// + [Key(3)] + [NotNull] + public IEnumerable Mods { get; set; } = Enumerable.Empty(); + + [IgnoreMember] public User? User { get; set; } [JsonConstructor] - public MultiplayerRoomUser(in int userId) + public MultiplayerRoomUser(int userId) { UserID = userId; } diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs index f0e11b2b8b..0f7050596f 100644 --- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,13 +16,11 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Extensions; 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.Online.Rooms.RoomStatuses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Users; using osu.Game.Utils; @@ -65,10 +64,12 @@ namespace osu.Game.Online.Multiplayer /// public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); + public readonly Bindable CurrentMatchPlayingItem = new Bindable(); + /// /// The corresponding to the local player, if available. /// - public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id); + public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); /// /// Whether the is the host in . @@ -82,21 +83,23 @@ namespace osu.Game.Online.Multiplayer } } + [Resolved] + protected IAPIProvider API { get; private set; } = null!; + + [Resolved] + protected RulesetStore Rulesets { get; private set; } = null!; + [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private RulesetStore rulesets { get; set; } = null!; + // Only exists for compatibility with old osu-server-spectator build. + // Todo: Can be removed on 2021/02/26. + private long defaultPlaylistItemId; private Room? apiRoom; - // Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise. - private int playlistItemId; - - protected StatefulMultiplayerClient() + [BackgroundDependencyLoader] + private void load() { IsConnected.BindValueChanged(connected => { @@ -104,35 +107,48 @@ namespace osu.Game.Online.Multiplayer if (!connected.NewValue && Room != null) { Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); - LeaveRoom().CatchUnobservedExceptions(); + LeaveRoom(); } }); } + private readonly TaskChain joinOrLeaveTaskChain = new TaskChain(); + private CancellationTokenSource? joinCancellationSource; + /// /// Joins the for a given API . /// /// The API . public async Task JoinRoom(Room room) { - if (Room != null) - throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + var cancellationSource = joinCancellationSource = new CancellationTokenSource(); - Debug.Assert(room.RoomID.Value != null); + await joinOrLeaveTaskChain.Add(async () => + { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); - apiRoom = room; - playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0; + Debug.Assert(room.RoomID.Value != null); - Room = await JoinRoom(room.RoomID.Value.Value); + // Join the server-side room. + var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false); + Debug.Assert(joinedRoom != null); - Debug.Assert(Room != null); + // Populate users. + Debug.Assert(joinedRoom.Users != null); + await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); - var users = await getRoomUsers(); - Debug.Assert(users != null); + // Update the stored room (must be done on update thread for thread-safety). + await scheduleAsync(() => + { + Room = joinedRoom; + apiRoom = room; + defaultPlaylistItemId = apiRoom.Playlist.FirstOrDefault()?.ID ?? 0; + }, cancellationSource.Token).ConfigureAwait(false); - await Task.WhenAll(users.Select(PopulateUser)); - - updateLocalRoomSettings(Room.Settings); + // Update room settings. + await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false); + }, cancellationSource.Token).ConfigureAwait(false); } /// @@ -142,23 +158,33 @@ namespace osu.Game.Online.Multiplayer /// The joined . protected abstract Task JoinRoom(long roomId); - public virtual Task LeaveRoom() + public Task LeaveRoom() { - Scheduler.Add(() => - { - if (Room == null) - return; + // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. + // This includes the setting of Room itself along with the initial update of the room settings on join. + joinCancellationSource?.Cancel(); + // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background. + // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed. + // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. + var scheduledReset = scheduleAsync(() => + { apiRoom = null; Room = null; CurrentMatchPlayingUserIds.Clear(); RoomUpdated?.Invoke(); - }, false); + }); - return Task.CompletedTask; + return joinOrLeaveTaskChain.Add(async () => + { + await scheduledReset.ConfigureAwait(false); + await LeaveRoomInternal().ConfigureAwait(false); + }); } + protected abstract Task LeaveRoomInternal(); + /// /// Change the current settings. /// @@ -192,7 +218,8 @@ namespace osu.Game.Online.Multiplayer BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, RulesetID = item.GetOr(existingPlaylistItem).RulesetID, - Mods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods + RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, + AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, }); } @@ -210,11 +237,11 @@ namespace osu.Game.Online.Multiplayer switch (localUser.State) { case MultiplayerUserState.Idle: - await ChangeState(MultiplayerUserState.Ready); + await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false); return; case MultiplayerUserState.Ready: - await ChangeState(MultiplayerUserState.Idle); + await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); return; default: @@ -230,6 +257,14 @@ namespace osu.Game.Online.Multiplayer public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + /// + /// Change the local user's mods in the currently joined room. + /// + /// The proposed new mods, excluding any required by the room itself. + public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList()); + + public abstract Task ChangeUserMods(IEnumerable newMods); + public abstract Task StartMatch(); Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) @@ -272,7 +307,7 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - await PopulateUser(user); + await PopulateUser(user).ConfigureAwait(false); Scheduler.Add(() => { @@ -378,6 +413,27 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + public Task UserModsChanged(int userId, IEnumerable mods) + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - user mods are mostly for display. + if (user == null) + return; + + user.Mods = mods; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; + } + Task IMultiplayerClient.LoadRequested() { if (Room == null) @@ -430,28 +486,7 @@ namespace osu.Game.Online.Multiplayer /// Populates the for a given . /// /// The to populate. - protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID); - - /// - /// Retrieve a copy of users currently in the joined in a thread-safe manner. - /// This should be used whenever accessing users from outside of an Update thread context (ie. when not calling ). - /// - /// A copy of users in the current room, or null if unavailable. - private Task?> getRoomUsers() - { - var tcs = new TaskCompletionSource?>(); - - // at some point we probably want to replace all these schedule calls with Room.LockForUpdate. - // for now, as this would require quite some consideration due to the number of accesses to the room instance, - // let's just add a manual schedule for the non-scheduled usages instead. - Scheduler.Add(() => - { - var users = Room?.Users.ToList(); - tcs.SetResult(users); - }, false); - - return tcs.Task; - } + protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false); /// /// Updates the local room settings with the given . @@ -460,62 +495,81 @@ namespace osu.Game.Online.Multiplayer /// This updates both the joined and the respective API . /// /// The new to update from. - private void updateLocalRoomSettings(MultiplayerRoomSettings settings) + /// The to cancel the update. + private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() => { if (Room == null) return; - Scheduler.Add(() => + Debug.Assert(apiRoom != null); + + // Update a few properties of the room instantaneously. + Room.Settings = settings; + apiRoom.Name.Value = Room.Settings.Name; + + // The current item update is delayed until an online beatmap lookup (below) succeeds. + // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. + CurrentMatchPlayingItem.Value = null; + + RoomUpdated?.Invoke(); + + GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() => { - if (Room == null) + if (cancellationToken.IsCancellationRequested) return; - Debug.Assert(apiRoom != null); + updatePlaylist(settings, set.Result); + }), TaskContinuationOptions.OnlyOnRanToCompletion); + }, cancellationToken); - // Update a few properties of the room instantaneously. - Room.Settings = settings; - apiRoom.Name.Value = Room.Settings.Name; - - // The playlist update is delayed until an online beatmap lookup (below) succeeds. - // In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here. - apiRoom.Playlist.Clear(); - - RoomUpdated?.Invoke(); - - var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId); - req.Success += res => updatePlaylist(settings, res); - - api.Queue(req); - }, false); - } - - private void updatePlaylist(MultiplayerRoomSettings settings, APIBeatmapSet onlineSet) + private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet) { if (Room == null || !Room.Settings.Equals(settings)) return; Debug.Assert(apiRoom != null); - var beatmapSet = onlineSet.ToBeatmapSet(rulesets); var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); beatmap.MD5Hash = settings.BeatmapChecksum; - var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance(); - var mods = settings.Mods.Select(m => m.ToMod(ruleset)); + var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance(); + var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); + var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); - PlaylistItem playlistItem = new PlaylistItem + // Try to retrieve the existing playlist item from the API room. + var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); + + if (playlistItem != null) + updateItem(playlistItem); + else { - ID = playlistItemId, - Beatmap = { Value = beatmap }, - Ruleset = { Value = ruleset.RulesetInfo }, - }; + // An existing playlist item does not exist, so append a new one. + updateItem(playlistItem = new PlaylistItem()); + apiRoom.Playlist.Add(playlistItem); + } - playlistItem.RequiredMods.AddRange(mods); + CurrentMatchPlayingItem.Value = playlistItem; - apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity. - apiRoom.Playlist.Add(playlistItem); + void updateItem(PlaylistItem item) + { + item.ID = settings.PlaylistItemId == 0 ? defaultPlaylistItemId : settings.PlaylistItemId; + item.Beatmap.Value = beatmap; + item.Ruleset.Value = ruleset.RulesetInfo; + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(mods); + item.AllowedMods.Clear(); + item.AllowedMods.AddRange(allowedMods); + } } + /// + /// Retrieves a from an online source. + /// + /// The beatmap set ID. + /// A token to cancel the request. + /// The retrieval task. + protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default); + /// /// For the provided user ID, update whether the user is included in . /// @@ -534,5 +588,31 @@ namespace osu.Game.Online.Multiplayer else CurrentMatchPlayingUserIds.Remove(userId); } + + private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + + Scheduler.Add(() => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(); + return; + } + + try + { + action(); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; + } } } diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index c9fb70f0cc..8868f90524 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -15,7 +15,7 @@ namespace osu.Game.Online /// A for displaying online content which require a local user to be logged in. /// Shows its children only when the local user is logged in and supports displaying a placeholder if not. /// - public abstract class OnlineViewContainer : Container + public class OnlineViewContainer : Container { protected LoadingSpinner LoadingSpinner { get; private set; } @@ -30,7 +30,7 @@ namespace osu.Game.Online [Resolved] protected IAPIProvider API { get; private set; } - protected OnlineViewContainer(string placeholderMessage) + public OnlineViewContainer(string placeholderMessage) { this.placeholderMessage = placeholderMessage; } diff --git a/osu.Game/Online/Rooms/BeatmapAvailability.cs b/osu.Game/Online/Rooms/BeatmapAvailability.cs index e7dbc5f436..a83327aad5 100644 --- a/osu.Game/Online/Rooms/BeatmapAvailability.cs +++ b/osu.Game/Online/Rooms/BeatmapAvailability.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using MessagePack; using Newtonsoft.Json; namespace osu.Game.Online.Rooms @@ -9,27 +10,30 @@ namespace osu.Game.Online.Rooms /// /// The local availability information about a certain beatmap for the client. /// + [MessagePackObject] public class BeatmapAvailability : IEquatable { /// /// The beatmap's availability state. /// + [Key(0)] public readonly DownloadState State; /// /// The beatmap's downloading progress, null when not in state. /// - public readonly double? DownloadProgress; + [Key(1)] + public readonly float? DownloadProgress; [JsonConstructor] - private BeatmapAvailability(DownloadState state, double? downloadProgress = null) + public BeatmapAvailability(DownloadState state, float? downloadProgress = null) { State = state; DownloadProgress = downloadProgress; } public static BeatmapAvailability NotDownloaded() => new BeatmapAvailability(DownloadState.NotDownloaded); - public static BeatmapAvailability Downloading(double progress) => new BeatmapAvailability(DownloadState.Downloading, progress); + public static BeatmapAvailability Downloading(float progress) => new BeatmapAvailability(DownloadState.Downloading, progress); public static BeatmapAvailability Importing() => new BeatmapAvailability(DownloadState.Importing); public static BeatmapAvailability LocallyAvailable() => new BeatmapAvailability(DownloadState.LocallyAvailable); diff --git a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs index afd0dadc7e..d4303e77df 100644 --- a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs @@ -9,11 +9,11 @@ namespace osu.Game.Online.Rooms { public class CreateRoomScoreRequest : APIRequest { - private readonly int roomId; - private readonly int playlistItemId; + private readonly long roomId; + private readonly long playlistItemId; private readonly string versionHash; - public CreateRoomScoreRequest(int roomId, int playlistItemId, string versionHash) + public CreateRoomScoreRequest(long roomId, long playlistItemId, string versionHash) { this.roomId = roomId; this.playlistItemId = playlistItemId; diff --git a/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs b/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs index 15f1221a00..67e2a2b27f 100644 --- a/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs @@ -7,9 +7,9 @@ namespace osu.Game.Online.Rooms { public class GetRoomLeaderboardRequest : APIRequest { - private readonly int roomId; + private readonly long roomId; - public GetRoomLeaderboardRequest(int roomId) + public GetRoomLeaderboardRequest(long roomId) { this.roomId = roomId; } diff --git a/osu.Game/Online/Rooms/GetRoomRequest.cs b/osu.Game/Online/Rooms/GetRoomRequest.cs index ce117075c7..853873901e 100644 --- a/osu.Game/Online/Rooms/GetRoomRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomRequest.cs @@ -7,9 +7,9 @@ namespace osu.Game.Online.Rooms { public class GetRoomRequest : APIRequest { - public readonly int RoomId; + public readonly long RoomId; - public GetRoomRequest(int roomId) + public GetRoomRequest(long roomId) { RoomId = roomId; } diff --git a/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs b/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs index 43f80a2dc4..abce2093e3 100644 --- a/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs +++ b/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs @@ -15,8 +15,8 @@ namespace osu.Game.Online.Rooms /// public class IndexPlaylistScoresRequest : APIRequest { - public readonly int RoomId; - public readonly int PlaylistItemId; + public readonly long RoomId; + public readonly long PlaylistItemId; [CanBeNull] public readonly Cursor Cursor; @@ -24,13 +24,13 @@ namespace osu.Game.Online.Rooms [CanBeNull] public readonly IndexScoresParams IndexParams; - public IndexPlaylistScoresRequest(int roomId, int playlistItemId) + public IndexPlaylistScoresRequest(long roomId, long playlistItemId) { RoomId = roomId; PlaylistItemId = playlistItemId; } - public IndexPlaylistScoresRequest(int roomId, int playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) + public IndexPlaylistScoresRequest(long roomId, long playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) : this(roomId, playlistItemId) { Cursor = cursor; diff --git a/osu.Game/Online/Rooms/ItemAttemptsCount.cs b/osu.Game/Online/Rooms/ItemAttemptsCount.cs new file mode 100644 index 0000000000..298603d778 --- /dev/null +++ b/osu.Game/Online/Rooms/ItemAttemptsCount.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 Newtonsoft.Json; + +namespace osu.Game.Online.Rooms +{ + /// + /// Represents attempts on a specific playlist item. + /// + public class ItemAttemptsCount + { + [JsonProperty("id")] + public int PlaylistItemID { get; set; } + + [JsonProperty("attempts")] + public int Attempts { get; set; } + } +} diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 677a3d3026..30c1d2f826 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -18,7 +18,7 @@ namespace osu.Game.Online.Rooms public class MultiplayerScore { [JsonProperty("id")] - public int ID { get; set; } + public long ID { get; set; } [JsonProperty("user")] public User User { get; set; } diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs new file mode 100644 index 0000000000..d6f4c45a75 --- /dev/null +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs @@ -0,0 +1,107 @@ +// 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.Bindables; +using osu.Framework.Logging; +using osu.Framework.Threading; +using osu.Game.Beatmaps; + +namespace osu.Game.Online.Rooms +{ + /// + /// Represent a checksum-verifying beatmap availability tracker usable for online play screens. + /// + /// This differs from a regular download tracking composite as this accounts for the + /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. + /// + public class OnlinePlayBeatmapAvailablilityTracker : DownloadTrackingComposite + { + public readonly IBindable SelectedItem = new Bindable(); + + /// + /// The availability state of the currently selected playlist item. + /// + public IBindable Availability => availability; + + private readonly Bindable availability = new Bindable(BeatmapAvailability.LocallyAvailable()); + + private ScheduledDelegate progressUpdate; + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(item => + { + // the underlying playlist is regularly cleared for maintenance purposes (things which probably need to be fixed eventually). + // to avoid exposing a state change when there may actually be none, ignore all nulls for now. + if (item.NewValue == null) + return; + + Model.Value = item.NewValue.Beatmap.Value.BeatmapSet; + }, true); + + Progress.BindValueChanged(_ => + { + // incoming progress changes are going to be at a very high rate. + // we don't want to flood the network with this, so rate limit how often we send progress updates. + if (progressUpdate?.Completed != false) + progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500); + }); + + State.BindValueChanged(_ => updateAvailability(), true); + } + + protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet) + { + int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; + string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; + + var matchingBeatmap = databasedSet.Beatmaps.FirstOrDefault(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); + + if (matchingBeatmap == null) + { + Logger.Log("The imported beatmap set does not match the online version.", LoggingTarget.Runtime, LogLevel.Important); + return false; + } + + return true; + } + + protected override bool IsModelAvailableLocally() + { + int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; + string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; + + var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); + return beatmap?.BeatmapSet.DeletePending == false; + } + + private void updateAvailability() + { + switch (State.Value) + { + case DownloadState.NotDownloaded: + availability.Value = BeatmapAvailability.NotDownloaded(); + break; + + case DownloadState.Downloading: + availability.Value = BeatmapAvailability.Downloading((float)Progress.Value); + break; + + case DownloadState.Importing: + availability.Value = BeatmapAvailability.Importing(); + break; + + case DownloadState.LocallyAvailable: + availability.Value = BeatmapAvailability.LocallyAvailable(); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(State)); + } + } + } +} diff --git a/osu.Game/Online/Rooms/PlaylistAggregateScore.cs b/osu.Game/Online/Rooms/PlaylistAggregateScore.cs new file mode 100644 index 0000000000..61e0951cd5 --- /dev/null +++ b/osu.Game/Online/Rooms/PlaylistAggregateScore.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.Online.Rooms +{ + /// + /// Represents aggregated score for the local user for a playlist. + /// + public class PlaylistAggregateScore + { + [JsonProperty("playlist_item_attempts")] + public ItemAttemptsCount[] PlaylistItemAttempts { get; set; } + } +} diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index ada2140ca6..1d409d4b56 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -15,7 +15,7 @@ namespace osu.Game.Online.Rooms public class PlaylistItem : IEquatable { [JsonProperty("id")] - public int ID { get; set; } + public long ID { get; set; } [JsonProperty("beatmap_id")] public int BeatmapID { get; set; } @@ -23,6 +23,12 @@ namespace osu.Game.Online.Rooms [JsonProperty("ruleset_id")] public int RulesetID { get; set; } + /// + /// Whether this is still a valid selection for the . + /// + [JsonProperty("expired")] + public bool Expired { get; set; } + [JsonIgnore] public readonly Bindable Beatmap = new Bindable(); diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 763ba25d52..b28680ffef 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -17,7 +17,7 @@ namespace osu.Game.Online.Rooms { [Cached] [JsonProperty("id")] - public readonly Bindable RoomID = new Bindable(); + public readonly Bindable RoomID = new Bindable(); [Cached] [JsonProperty("name")] @@ -72,6 +72,10 @@ namespace osu.Game.Online.Rooms [JsonIgnore] public readonly Bindable MaxParticipants = new Bindable(); + [Cached] + [JsonProperty("current_user_score")] + public readonly Bindable UserScore = new Bindable(); + [Cached] [JsonProperty("recent_participants")] public readonly BindableList RecentParticipants = new BindableList(); @@ -144,10 +148,17 @@ namespace osu.Game.Online.Rooms MaxParticipants.Value = other.MaxParticipants.Value; ParticipantCount.Value = other.ParticipantCount.Value; EndDate.Value = other.EndDate.Value; + UserScore.Value = other.UserScore.Value; if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); + // Todo: This is not the best way/place to do this, but the intention is to display all playlist items when the room has ended, + // and display only the non-expired playlist items while the room is still active. In order to achieve this, all expired items are removed from the source Room. + // More refactoring is required before this can be done locally instead - DrawableRoomPlaylist is currently directly bound to the playlist to display items in the room. + if (!(Status.Value is RoomStatusEnded)) + other.Playlist.RemoveAll(i => i.Expired); + if (!Playlist.SequenceEqual(other.Playlist)) { Playlist.Clear(); diff --git a/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs b/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs index 3f728a5417..ba3e3c6349 100644 --- a/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs +++ b/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs @@ -7,11 +7,11 @@ namespace osu.Game.Online.Rooms { public class ShowPlaylistUserScoreRequest : APIRequest { - private readonly int roomId; - private readonly int playlistItemId; + private readonly long roomId; + private readonly long playlistItemId; private readonly long userId; - public ShowPlaylistUserScoreRequest(int roomId, int playlistItemId, long userId) + public ShowPlaylistUserScoreRequest(long roomId, long playlistItemId, long userId) { this.roomId = roomId; this.playlistItemId = playlistItemId; diff --git a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs index 5a78b9fabd..9e432fa99e 100644 --- a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs @@ -11,12 +11,12 @@ namespace osu.Game.Online.Rooms { public class SubmitRoomScoreRequest : APIRequest { - private readonly int scoreId; - private readonly int roomId; - private readonly int playlistItemId; + private readonly long scoreId; + private readonly long roomId; + private readonly long playlistItemId; private readonly ScoreInfo scoreInfo; - public SubmitRoomScoreRequest(int scoreId, int roomId, int playlistItemId, ScoreInfo scoreInfo) + public SubmitRoomScoreRequest(long scoreId, long roomId, long playlistItemId, ScoreInfo scoreInfo) { this.scoreId = scoreId; this.roomId = roomId; diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs index a8d0434324..0e59cdf4ce 100644 --- a/osu.Game/Online/Spectator/FrameDataBundle.cs +++ b/osu.Game/Online/Spectator/FrameDataBundle.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using MessagePack; using Newtonsoft.Json; using osu.Game.Replays.Legacy; using osu.Game.Scoring; @@ -12,10 +13,13 @@ using osu.Game.Scoring; namespace osu.Game.Online.Spectator { [Serializable] + [MessagePackObject] public class FrameDataBundle { + [Key(0)] public FrameHeader Header { get; set; } + [Key(1)] public IEnumerable Frames { get; set; } public FrameDataBundle(ScoreInfo score, IEnumerable frames) diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index 135b356eda..adfcbcd95a 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using MessagePack; using Newtonsoft.Json; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -12,31 +13,37 @@ using osu.Game.Scoring; namespace osu.Game.Online.Spectator { [Serializable] + [MessagePackObject] public class FrameHeader { /// /// The current accuracy of the score. /// + [Key(0)] public double Accuracy { get; set; } /// /// The current combo of the score. /// + [Key(1)] public int Combo { get; set; } /// /// The maximum combo achieved up to the current point in time. /// + [Key(2)] public int MaxCombo { get; set; } /// /// Cumulative hit statistics. /// + [Key(3)] public Dictionary Statistics { get; set; } /// /// The time at which this frame was received by the server. /// + [Key(4)] public DateTimeOffset ReceivedTime { get; set; } /// @@ -54,7 +61,8 @@ namespace osu.Game.Online.Spectator } [JsonConstructor] - public FrameHeader(int combo, int maxCombo, double accuracy, Dictionary statistics, DateTimeOffset receivedTime) + [SerializationConstructor] + public FrameHeader(double accuracy, int combo, int maxCombo, Dictionary statistics, DateTimeOffset receivedTime) { Combo = combo; MaxCombo = maxCombo; diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs index 101ce3d5d5..96a875bc14 100644 --- a/osu.Game/Online/Spectator/SpectatorState.cs +++ b/osu.Game/Online/Spectator/SpectatorState.cs @@ -5,18 +5,23 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using MessagePack; using osu.Game.Online.API; namespace osu.Game.Online.Spectator { [Serializable] + [MessagePackObject] public class SpectatorState : IEquatable { + [Key(0)] public int? BeatmapID { get; set; } + [Key(1)] public int? RulesetID { get; set; } [NotNull] + [Key(2)] public IEnumerable Mods { get; set; } = Enumerable.Empty(); public bool Equals(SpectatorState other) => BeatmapID == other?.BeatmapID && Mods.SequenceEqual(other?.Mods) && RulesetID == other?.RulesetID; diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs index 344b73f3d9..3a586874fe 100644 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs @@ -8,12 +8,9 @@ using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Replays.Legacy; @@ -33,7 +30,14 @@ namespace osu.Game.Online.Spectator /// public const double TIME_BETWEEN_SENDS = 200; - private HubConnection connection; + private readonly string endpoint; + + [CanBeNull] + private IHubClientConnector connector; + + private readonly IBindable isConnected = new BindableBool(); + + private HubConnection connection => connector?.CurrentConnection; private readonly List watchingUsers = new List(); @@ -43,13 +47,6 @@ namespace osu.Game.Online.Spectator private readonly BindableList playingUsers = new BindableList(); - private readonly IBindable apiState = new Bindable(); - - private bool isConnected; - - [Resolved] - private IAPIProvider api { get; set; } - [CanBeNull] private IBeatmap currentBeatmap; @@ -81,80 +78,32 @@ namespace osu.Game.Online.Spectator /// public event Action OnUserFinishedPlaying; - private readonly string endpoint; - public SpectatorStreamingClient(EndpointConfiguration endpoints) { endpoint = endpoints.SpectatorEndpointUrl; } [BackgroundDependencyLoader] - private void load() + private void load(IAPIProvider api) { - apiState.BindTo(api.State); - apiState.BindValueChanged(apiStateChanged, true); - } + connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint); - private void apiStateChanged(ValueChangedEvent state) - { - switch (state.NewValue) + if (connector != null) { - case APIState.Failing: - case APIState.Offline: - connection?.StopAsync(); - connection = null; - break; - - case APIState.Online: - Task.Run(Connect); - break; - } - } - - protected virtual async Task Connect() - { - if (connection != null) - return; - - connection = new HubConnectionBuilder() - .WithUrl(endpoint, options => - { - options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); - }) - .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }) - .Build(); - - // until strong typed client support is added, each method must be manually bound (see https://github.com/dotnet/aspnetcore/issues/15198) - connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); - connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); - connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); - - connection.Closed += async ex => - { - isConnected = false; - playingUsers.Clear(); - - if (ex != null) + connector.ConfigureConnection = connection => { - Logger.Log($"Spectator client lost connection: {ex}", LoggingTarget.Network); - await tryUntilConnected(); - } - }; + // until strong typed client support is added, each method must be manually bound + // (see https://github.com/dotnet/aspnetcore/issues/15198) + connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); + connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); + connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + }; - await tryUntilConnected(); - - async Task tryUntilConnected() - { - Logger.Log("Spectator client connecting...", LoggingTarget.Network); - - while (api.State.Value == APIState.Online) + isConnected.BindTo(connector.IsConnected); + isConnected.BindValueChanged(connected => { - try + if (connected.NewValue) { - // reconnect on any failure - await connection.StartAsync(); - Logger.Log("Spectator client connected!", LoggingTarget.Network); - // get all the users that were previously being watched int[] users; @@ -164,25 +113,19 @@ namespace osu.Game.Online.Spectator watchingUsers.Clear(); } - // success - isConnected = true; - - // resubscribe to watched users + // resubscribe to watched users. foreach (var userId in users) WatchUser(userId); // re-send state in case it wasn't received if (isPlaying) beginPlaying(); - - break; } - catch (Exception e) + else { - Logger.Log($"Spectator client connection error: {e}", LoggingTarget.Network); - await Task.Delay(5000); + playingUsers.Clear(); } - } + }, true); } } @@ -234,14 +177,14 @@ namespace osu.Game.Online.Spectator { Debug.Assert(isPlaying); - if (!isConnected) return; + if (!isConnected.Value) return; connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState); } public void SendFrames(FrameDataBundle data) { - if (!isConnected) return; + if (!isConnected.Value) return; lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); } @@ -251,7 +194,7 @@ namespace osu.Game.Online.Spectator isPlaying = false; currentBeatmap = null; - if (!isConnected) return; + if (!isConnected.Value) return; connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState); } @@ -265,7 +208,7 @@ namespace osu.Game.Online.Spectator watchingUsers.Add(userId); - if (!isConnected) + if (!isConnected.Value) return; } @@ -278,7 +221,7 @@ namespace osu.Game.Online.Spectator { watchingUsers.Remove(userId); - if (!isConnected) + if (!isConnected.Value) return; } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 5acd6bc73d..66b9141ce8 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -28,7 +28,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; @@ -52,6 +51,7 @@ using osu.Game.Updater; using osu.Game.Utils; using LogLevel = osu.Framework.Logging.LogLevel; using osu.Game.Database; +using osu.Game.IO; namespace osu.Game { @@ -88,7 +88,7 @@ namespace osu.Game protected SentryLogger SentryLogger; - public virtual Storage GetStorageForStableInstall() => null; + public virtual StableStorage GetStorageForStableInstall() => null; public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); @@ -361,14 +361,6 @@ namespace osu.Game PerformFromScreen(screen => { - // we might already be at song select, so a check is required before performing the load to solo. - if (screen is MainMenu) - menuScreen.LoadToSolo(); - - // we might even already be at the song - if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash && (difficultyCriteria?.Invoke(Beatmap.Value.BeatmapInfo) ?? true)) - return; - // Find beatmaps that match our predicate. var beatmaps = databasedSet.Beatmaps.Where(b => difficultyCriteria?.Invoke(b) ?? true).ToList(); @@ -381,9 +373,16 @@ namespace osu.Game ?? beatmaps.FirstOrDefault(b => b.Ruleset.Equals(Ruleset.Value)) ?? beatmaps.First(); - Ruleset.Value = selection.Ruleset; - Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); - }, validScreens: new[] { typeof(PlaySongSelect) }); + if (screen is IHandlePresentBeatmap presentableScreen) + { + presentableScreen.PresentBeatmap(BeatmapManager.GetWorkingBeatmap(selection), selection.Ruleset); + } + else + { + Ruleset.Value = selection.Ruleset; + Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); + } + }, validScreens: new[] { typeof(SongSelect), typeof(IHandlePresentBeatmap) }); } /// @@ -441,7 +440,7 @@ namespace osu.Game public override Task Import(params ImportTask[] imports) { // encapsulate task as we don't want to begin the import process until in a ready state. - var importTask = new Task(async () => await base.Import(imports)); + var importTask = new Task(async () => await base.Import(imports).ConfigureAwait(false)); waitForReady(() => this, _ => importTask.Start()); @@ -468,6 +467,16 @@ namespace osu.Game private void modsChanged(ValueChangedEvent> mods) { updateModDefaults(); + + // a lease may be taken on the mods bindable, at which point we can't really ensure valid mods. + if (SelectedMods.Disabled) + return; + + if (!ModUtils.CheckValidForGameplay(mods.NewValue, out var invalid)) + { + // ensure we always have a valid set of mods. + SelectedMods.Value = mods.NewValue.Except(invalid).ToArray(); + } } private void updateModDefaults() @@ -768,7 +777,7 @@ namespace osu.Game if (recentLogCount < short_term_display_limit) { - Schedule(() => notifications.Post(new SimpleNotification + Schedule(() => notifications.Post(new SimpleErrorNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), @@ -822,7 +831,7 @@ namespace osu.Game asyncLoadStream = Task.Run(async () => { if (previousLoadStream != null) - await previousLoadStream; + await previousLoadStream.ConfigureAwait(false); try { @@ -836,7 +845,7 @@ namespace osu.Game // The delegate won't complete if OsuGame has been disposed in the meantime while (!IsDisposed && !del.Completed) - await Task.Delay(10); + await Task.Delay(10).ConfigureAwait(false); // Either we're disposed or the load process has started successfully if (IsDisposed) @@ -844,7 +853,7 @@ namespace osu.Game Debug.Assert(task != null); - await task; + await task.ConfigureAwait(false); Logger.Log($"Loaded {component}!", level: LogLevel.Debug); } @@ -871,22 +880,13 @@ namespace osu.Game switch (action) { case GlobalAction.ResetInputSettings: - var sensitivity = frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity); - - sensitivity.Disabled = false; - sensitivity.Value = 1; - sensitivity.Disabled = true; - - frameworkConfig.Set(FrameworkSetting.IgnoredInputHandlers, string.Empty); + Host.ResetInputHandlers(); frameworkConfig.GetBindable(FrameworkSetting.ConfineMouseMode).SetDefault(); return true; - case GlobalAction.ToggleToolbar: - Toolbar.ToggleVisibility(); - return true; - case GlobalAction.ToggleGameplayMouseButtons: - LocalConfig.Set(OsuSetting.MouseDisableButtons, !LocalConfig.Get(OsuSetting.MouseDisableButtons)); + var mouseDisableButtons = LocalConfig.GetBindable(OsuSetting.MouseDisableButtons); + mouseDisableButtons.Value = !mouseDisableButtons.Value; return true; case GlobalAction.RandomSkin: diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 7806a38cd9..e8d0475113 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -31,6 +31,7 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online; +using osu.Game.Online.Chat; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Overlays; @@ -98,7 +99,14 @@ namespace osu.Game [Cached(typeof(IBindable))] protected readonly Bindable Ruleset = new Bindable(); - // todo: move this to SongSelect once Screen has the ability to unsuspend. + /// + /// The current mod selection for the local user. + /// + /// + /// If a mod select overlay is present, mod instances set to this value are not guaranteed to remain as the provided instance and will be overwritten by a copy. + /// In such a case, changes to settings of a mod will *not* propagate after a mod is added to this collection. + /// As such, all settings should be finalised before adding a mod to this collection. + /// [Cached] [Cached(typeof(IBindable>))] protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); @@ -150,6 +158,13 @@ namespace osu.Game protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); + /// + /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. + /// + internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; + + private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); + [BackgroundDependencyLoader] private void load() { @@ -221,7 +236,9 @@ namespace osu.Game EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); - dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints)); + MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; + + dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints)); dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints)); @@ -279,9 +296,10 @@ namespace osu.Game RegisterImportHandler(ScoreManager); RegisterImportHandler(SkinManager); - // tracks play so loud our samples can't keep up. - // this adds a global reduction of track volume for the time being. - Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, new BindableDouble(0.8)); + // drop track volume game-wide to leave some head-room for UI effects / samples. + // this means that for the time being, gameplay sample playback is louder relative to the audio track, compared to stable. + // we may want to revisit this if users notice or complain about the difference (consider this a bit of a trial). + Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); Beatmap = new NonNullableBindable(defaultBeatmap); @@ -457,7 +475,7 @@ namespace osu.Game foreach (var importer in fileImporters) { if (importer.HandledExtensions.Contains(extension)) - await importer.Import(paths); + await importer.Import(paths).ConfigureAwait(false); } } @@ -468,7 +486,7 @@ namespace osu.Game { var importer = fileImporters.FirstOrDefault(i => i.HandledExtensions.Contains(taskGroup.Key)); return importer?.Import(taskGroup.ToArray()) ?? Task.CompletedTask; - })); + })).ConfigureAwait(false); } public IEnumerable HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs index b429a5277b..01bcbd3244 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs @@ -12,7 +12,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osuTK; using Humanizer; -using osu.Game.Utils; +using osu.Framework.Extensions.EnumExtensions; namespace osu.Game.Overlays.BeatmapListing { @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.BeatmapListing if (typeof(T).IsEnum) { - foreach (var val in OrderAttributeUtils.GetValuesInOrder()) + foreach (var val in EnumExtensions.GetValuesInOrder()) AddItem(val); } } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs index 6a2f2e4569..ca94078401 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels public DownloadProgressBar(BeatmapSetInfo beatmapSet) : base(beatmapSet) { - AddInternal(progressBar = new InteractionDisabledProgressBar + AddInternal(progressBar = new ProgressBar(false) { Height = 0, Alpha = 0, @@ -64,11 +64,5 @@ namespace osu.Game.Overlays.BeatmapListing.Panels } }, true); } - - private class InteractionDisabledProgressBar : ProgressBar - { - public override bool HandlePositionalInput => false; - public override bool HandleNonPositionalInput => false; - } } } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs index c1d366bb82..4d5c387c4a 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs @@ -84,14 +84,14 @@ namespace osu.Game.Overlays.BeatmapListing.Panels { new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title)), + Text = new RomanisableString(SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title), Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) }, } }, new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist)), + Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) }, }, diff --git a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs index 76a30d1c11..00ffd168c1 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs @@ -107,14 +107,14 @@ namespace osu.Game.Overlays.BeatmapListing.Panels { new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title)), + Text = new RomanisableString(SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title), Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) }, } }, new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist)), + Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) }, } diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs index eee5d8f7e1..015cee8ce3 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Utils; +using osu.Framework.Utils; namespace osu.Game.Overlays.BeatmapListing { diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 0c9c995dd6..5df7a4650e 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -15,98 +15,82 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing.Panels; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays { - public class BeatmapListingOverlay : FullscreenOverlay + public class BeatmapListingOverlay : OnlineOverlay { [Resolved] private PreviewTrackManager previewTrackManager { get; set; } private Drawable currentContent; - private LoadingLayer loadingLayer; private Container panelTarget; private FillFlowContainer foundContent; private NotFoundDrawable notFoundContent; - - private OverlayScrollContainer resultScrollContainer; + private BeatmapListingFilterControl filterControl; public BeatmapListingOverlay() - : base(OverlayColourScheme.Blue, new BeatmapListingHeader()) + : base(OverlayColourScheme.Blue) { } - private BeatmapListingFilterControl filterControl; - [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + Child = new FillFlowContainer { - new Box + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6 - }, - resultScrollContainer = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer + filterControl = new BeatmapListingFilterControl + { + TypingStarted = onTypingStarted, + SearchStarted = onSearchStarted, + SearchFinished = onSearchFinished, + }, + new Container { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, Children = new Drawable[] { - Header, - filterControl = new BeatmapListingFilterControl + new Box { - TypingStarted = onTypingStarted, - SearchStarted = onSearchStarted, - SearchFinished = onSearchFinished, + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background4, }, - new Container + panelTarget = new Container { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 20 }, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background4, - }, - panelTarget = new Container - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 20 }, - Children = new Drawable[] - { - foundContent = new FillFlowContainer(), - notFoundContent = new NotFoundDrawable(), - } - } - }, - }, - } + foundContent = new FillFlowContainer(), + notFoundContent = new NotFoundDrawable(), + } + } + }, }, - }, - loadingLayer = new LoadingLayer(true) + } }; } + protected override BeatmapListingHeader CreateHeader() => new BeatmapListingHeader(); + + protected override Color4 BackgroundColour => ColourProvider.Background6; + private void onTypingStarted() { // temporary until the textbox/header is updated to always stay on screen. - resultScrollContainer.ScrollToStart(); + ScrollFlow.ScrollToStart(); } protected override void OnFocus(FocusEvent e) @@ -125,7 +109,7 @@ namespace osu.Game.Overlays previewTrackManager.StopAnyPlaying(this); if (panelTarget.Any()) - loadingLayer.Show(); + Loading.Show(); } private Task panelLoadDelegate; @@ -173,26 +157,37 @@ namespace osu.Game.Overlays private void addContentToPlaceholder(Drawable content) { - loadingLayer.Hide(); + Loading.Hide(); lastFetchDisplayedTime = Time.Current; + if (content == currentContent) + return; + var lastContent = currentContent; if (lastContent != null) { - lastContent.FadeOut(100, Easing.OutQuint).Expire(); + var transform = 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. - lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent)); + if (lastContent == notFoundContent) + { + // not found display may be used multiple times, so don't expire/dispose it. + transform.Schedule(() => panelTarget.Remove(lastContent)); + } + else + { + // 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. + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => lastContent.Expire()); + } } if (!content.IsAlive) panelTarget.Add(content); - content.FadeIn(200, Easing.OutQuint); + content.FadeInFromZero(200, Easing.OutQuint); currentContent = content; } @@ -202,7 +197,7 @@ namespace osu.Game.Overlays base.Dispose(isDisposing); } - private class NotFoundDrawable : CompositeDrawable + public class NotFoundDrawable : CompositeDrawable { public NotFoundDrawable() { @@ -256,7 +251,7 @@ namespace osu.Game.Overlays bool shouldShowMore = panelLoadDelegate?.IsCompleted != false && Time.Current - lastFetchDisplayedTime > time_between_fetches - && (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance)); + && (ScrollFlow.ScrollableExtent > 0 && ScrollFlow.IsScrolledToEnd(pagination_scroll_distance)); if (shouldShowMore) filterControl.FetchNextPage(); diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index a2464bef09..cf74c0d4d3 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -96,7 +97,7 @@ namespace osu.Game.Overlays.BeatmapSet public string TooltipText { get; } - public string Value + public LocalisableString Value { get => value.Text; set => this.value.Text = value; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 324299ccba..ddd1dfa6cd 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -15,7 +16,6 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Users.Drawables; -using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -105,7 +105,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores var ruleset = scores.First().Ruleset.CreateInstance(); - foreach (var result in OrderAttributeUtils.GetValuesInOrder()) + foreach (var result in EnumExtensions.GetValuesInOrder()) { if (!allScoreStatistics.Contains(result)) continue; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 93744dd6a3..5cb834b510 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -204,7 +203,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores this.text = text; } - public LocalisedString Text + public string Text { set => text.Text = value; } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index c16ec339bb..bdb3715e73 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -6,20 +6,19 @@ 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.Input.Events; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; 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; namespace osu.Game.Overlays { - public class BeatmapSetOverlay : FullscreenOverlay + public class BeatmapSetOverlay : OnlineOverlay { public const float X_PADDING = 40; public const float Y_PADDING = 25; @@ -33,55 +32,27 @@ namespace osu.Game.Overlays // receive input outside our bounds so we can trigger a close event on ourselves. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - private readonly Box background; - public BeatmapSetOverlay() - : base(OverlayColourScheme.Blue, new BeatmapSetHeader()) + : base(OverlayColourScheme.Blue) { - OverlayScrollContainer scroll; Info info; CommentsSection comments; - Children = new Drawable[] + Child = new FillFlowContainer { - background = new Box + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both - }, - scroll = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer + info = new Info(), + new ScoresContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Children = new[] - { - new BeatmapSetLayoutSection - { - Child = new ReverseChildIDFillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header, - info = new Info() - } - }, - }, - new ScoresContainer - { - Beatmap = { BindTarget = Header.HeaderContent.Picker.Beatmap } - }, - comments = new CommentsSection() - }, + Beatmap = { BindTarget = Header.HeaderContent.Picker.Beatmap } }, - }, + comments = new CommentsSection() + } }; Header.BeatmapSet.BindTo(beatmapSet); @@ -91,16 +62,13 @@ namespace osu.Game.Overlays Header.HeaderContent.Picker.Beatmap.ValueChanged += b => { info.Beatmap = b.NewValue; - - scroll.ScrollToStart(); + ScrollFlow.ScrollToStart(); }; } - [BackgroundDependencyLoader] - private void load() - { - background.Colour = ColourProvider.Background6; - } + protected override BeatmapSetHeader CreateHeader() => new BeatmapSetHeader(); + + protected override Color4 BackgroundColour => ColourProvider.Background6; protected override void PopOutComplete() { diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index c7e9a86fa4..e7d68853ad 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -11,68 +11,35 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Changelog; +using osuTK.Graphics; namespace osu.Game.Overlays { - public class ChangelogOverlay : FullscreenOverlay + public class ChangelogOverlay : OnlineOverlay { + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + public readonly Bindable Current = new Bindable(); - private Container content; - - private SampleChannel sampleBack; + private Sample sampleBack; private List builds; protected List Streams; public ChangelogOverlay() - : base(OverlayColourScheme.Purple, new ChangelogHeader()) + : base(OverlayColourScheme.Purple, false) { } [BackgroundDependencyLoader] private void load(AudioManager audio) { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background4, - }, - new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header.With(h => - { - h.ListingSelected = ShowListing; - h.Build.BindTarget = Current; - }), - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - }, - }, - }, - }; + Header.Build.BindTarget = Current; sampleBack = audio.Samples.Get(@"UI/generic-select-soft"); @@ -85,6 +52,13 @@ namespace osu.Game.Overlays }); } + protected override ChangelogHeader CreateHeader() => new ChangelogHeader + { + ListingSelected = ShowListing, + }; + + protected override Color4 BackgroundColour => ColourProvider.Background4; + public void ShowListing() { Current.Value = null; @@ -154,8 +128,11 @@ namespace osu.Game.Overlays private Task initialFetchTask; - private void performAfterFetch(Action action) => fetchListing()?.ContinueWith(_ => - Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); + private void performAfterFetch(Action action) => Schedule(() => + { + fetchListing()?.ContinueWith(_ => + Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); + }); private Task fetchListing() { @@ -188,26 +165,26 @@ namespace osu.Game.Overlays tcs.SetException(e); }; - await API.PerformAsync(req); + await API.PerformAsync(req).ConfigureAwait(false); - await tcs.Task; - }); + return tcs.Task; + }).Unwrap(); } private CancellationTokenSource loadContentCancellation; private void loadContent(ChangelogContent newContent) { - content.FadeTo(0.2f, 300, Easing.OutQuint); + Content.FadeTo(0.2f, 300, Easing.OutQuint); loadContentCancellation?.Cancel(); LoadComponentAsync(newContent, c => { - content.FadeIn(300, Easing.OutQuint); + Content.FadeIn(300, Easing.OutQuint); c.BuildSelected = ShowBuild; - content.Child = c; + Child = c; }, (loadContentCancellation = new CancellationTokenSource()).Token); } } diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 4eb348ae33..f43420e35e 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -190,13 +190,13 @@ namespace osu.Game.Overlays.Chat } } }; - - updateMessageContent(); } protected override void LoadComplete() { base.LoadComplete(); + + updateMessageContent(); FinishTransforms(true); } diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 5926d11c03..86ce724390 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Chat @@ -24,7 +25,7 @@ namespace osu.Game.Overlays.Chat { public readonly Channel Channel; protected FillFlowContainer ChatLineFlow; - private OsuScrollContainer scroll; + private ChannelScrollContainer scroll; private bool scrollbarVisible = true; @@ -56,7 +57,7 @@ namespace osu.Game.Overlays.Chat { RelativeSizeAxes = Axes.Both, Masking = true, - Child = scroll = new OsuScrollContainer + Child = scroll = new ChannelScrollContainer { ScrollbarVisible = scrollbarVisible, RelativeSizeAxes = Axes.Both, @@ -80,12 +81,6 @@ namespace osu.Game.Overlays.Chat Channel.PendingMessageResolved += pendingMessageResolved; } - protected override void LoadComplete() - { - base.LoadComplete(); - scrollToEnd(); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -113,8 +108,6 @@ namespace osu.Game.Overlays.Chat ChatLineFlow.Clear(); } - bool shouldScrollToEnd = scroll.IsScrolledToEnd(10) || !chatLines.Any() || newMessages.Any(m => m is LocalMessage); - // Add up to last Channel.MAX_HISTORY messages var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MAX_HISTORY)); @@ -153,8 +146,10 @@ namespace osu.Game.Overlays.Chat } } - if (shouldScrollToEnd) - scrollToEnd(); + // due to the scroll adjusts from old messages removal above, a scroll-to-end must be enforced, + // to avoid making the container think the user has scrolled back up and unwantedly disable auto-scrolling. + if (newMessages.Any(m => m is LocalMessage)) + scroll.ScrollToEnd(); }); private void pendingMessageResolved(Message existing, Message updated) => Schedule(() => @@ -178,8 +173,6 @@ namespace osu.Game.Overlays.Chat private IEnumerable chatLines => ChatLineFlow.Children.OfType(); - private void scrollToEnd() => ScheduleAfterChildren(() => scroll.ScrollToEnd()); - public class DaySeparator : Container { public float TextSize @@ -243,5 +236,51 @@ namespace osu.Game.Overlays.Chat }; } } + + /// + /// An with functionality to automatically scroll whenever the maximum scrollable distance increases. + /// + private class ChannelScrollContainer : UserTrackingScrollContainer + { + /// + /// The chat will be automatically scrolled to end if and only if + /// the distance between the current scroll position and the end of the scroll + /// is less than this value. + /// + private const float auto_scroll_leniency = 10f; + + private float? lastExtent; + + protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + { + base.OnUserScroll(value, animated, distanceDecay); + lastExtent = null; + } + + protected override void Update() + { + base.Update(); + + // If the user has scrolled to the bottom of the container, we should resume tracking new content. + if (UserScrolling && IsScrolledToEnd(auto_scroll_leniency)) + CancelUserScroll(); + + // If the user hasn't overridden our behaviour and there has been new content added to the container, we should update our scroll position to track it. + bool requiresScrollUpdate = !UserScrolling && (lastExtent == null || Precision.AlmostBigger(ScrollableExtent, lastExtent.Value)); + + if (requiresScrollUpdate) + { + // Schedule required to allow FillFlow to be the correct size. + Schedule(() => + { + if (!UserScrolling) + { + ScrollToEnd(); + lastExtent = ScrollableExtent; + } + }); + } + } + } } } diff --git a/osu.Game/Overlays/Chat/Selection/ChannelSection.cs b/osu.Game/Overlays/Chat/Selection/ChannelSection.cs index eac48ca5cb..537ac975ac 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelSection.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelSection.cs @@ -4,19 +4,17 @@ using System; using System.Collections.Generic; using System.Linq; -using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; +using osuTK; namespace osu.Game.Overlays.Chat.Selection { public class ChannelSection : Container, IHasFilterableChildren { - private readonly OsuSpriteText header; - public readonly FillFlowContainer ChannelFlow; public IEnumerable FilterableChildren => ChannelFlow.Children; @@ -29,12 +27,6 @@ namespace osu.Game.Overlays.Chat.Selection public bool FilteringActive { get; set; } - public string Header - { - get => header.Text; - set => header.Text = value.ToUpperInvariant(); - } - public IEnumerable Channels { set => ChannelFlow.ChildrenEnumerable = value.Select(c => new ChannelListItem(c)); @@ -47,9 +39,10 @@ namespace osu.Game.Overlays.Chat.Selection Children = new Drawable[] { - header = new OsuSpriteText + new OsuSpriteText { Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold), + Text = "All Channels".ToUpperInvariant() }, ChannelFlow = new FillFlowContainer { diff --git a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs index be9ecc6746..231d7ca63c 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs @@ -131,11 +131,7 @@ namespace osu.Game.Overlays.Chat.Selection { sectionsFlow.ChildrenEnumerable = new[] { - new ChannelSection - { - Header = "All Channels", - Channels = channels, - }, + new ChannelSection { Channels = channels, }, }; foreach (ChannelSection s in sectionsFlow.Children) diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 8bc7e21047..28f2287514 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -24,6 +24,7 @@ using osu.Game.Overlays.Chat.Tabs; using osuTK.Input; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Game.Online; namespace osu.Game.Overlays { @@ -118,40 +119,47 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both, }, - currentChannelContainer = new Container + new OnlineViewContainer("Sign in to chat") { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Bottom = textbox_height - }, - }, - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = textbox_height, - Padding = new MarginPadding - { - Top = padding * 2, - Bottom = padding * 2, - Left = ChatLine.LEFT_PADDING + padding * 2, - Right = padding * 2, - }, Children = new Drawable[] { - textbox = new FocusedTextBox + currentChannelContainer = new Container { RelativeSizeAxes = Axes.Both, - Height = 1, - PlaceholderText = "type your message", - ReleaseFocusOnCommit = false, - HoldFocus = true, - } - } - }, - loading = new LoadingSpinner(), + Padding = new MarginPadding + { + Bottom = textbox_height + }, + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = textbox_height, + Padding = new MarginPadding + { + Top = padding * 2, + Bottom = padding * 2, + Left = ChatLine.LEFT_PADDING + padding * 2, + Right = padding * 2, + }, + Children = new Drawable[] + { + textbox = new FocusedTextBox + { + RelativeSizeAxes = Axes.Both, + Height = 1, + PlaceholderText = "type your message", + ReleaseFocusOnCommit = false, + HoldFocus = true, + } + } + }, + loading = new LoadingSpinner(), + }, + } } }, tabsArea = new TabsArea diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs index 57bf2af4d2..2f7f16dd6f 100644 --- a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Comments.Buttons { public abstract class CommentRepliesButton : CompositeDrawable { - protected string Text + protected LocalisableString Text { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 31aa41e967..7c47ac655f 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -9,7 +9,6 @@ using osuTK; using osu.Game.Online.API.Requests.Responses; using osu.Game.Users.Drawables; using osu.Game.Graphics.Containers; -using osu.Game.Utils; using osu.Framework.Graphics.Cursor; using osu.Framework.Bindables; using System.Linq; @@ -245,11 +244,32 @@ namespace osu.Game.Overlays.Comments if (Comment.EditedAt.HasValue) { - info.Add(new OsuSpriteText + var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); + var colour = colourProvider.Foreground1; + + info.Add(new FillFlowContainer { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), - Text = $@"edited {HumanizerUtils.Humanize(Comment.EditedAt.Value)} by {Comment.EditedUser.Username}", - Colour = colourProvider.Foreground1 + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = font, + Text = "edited ", + Colour = colour + }, + new DrawableDate(Comment.EditedAt.Value) + { + Font = font, + Colour = colour + }, + new OsuSpriteText + { + Font = font, + Text = $@" by {Comment.EditedUser.Username}", + Colour = colour + }, + } }); } diff --git a/osu.Game/Overlays/Comments/VotePill.cs b/osu.Game/Overlays/Comments/VotePill.cs index aa9723ea85..cf3c470f96 100644 --- a/osu.Game/Overlays/Comments/VotePill.cs +++ b/osu.Game/Overlays/Comments/VotePill.cs @@ -33,11 +33,16 @@ namespace osu.Game.Overlays.Comments [Resolved] private IAPIProvider api { get; set; } + [Resolved(canBeNull: true)] + private LoginOverlay login { get; set; } + [Resolved] private OverlayColourProvider colourProvider { get; set; } + protected Box Background { get; private set; } + private readonly Comment comment; - private Box background; + private Box hoverLayer; private CircularContainer borderContainer; private SpriteText sideNumber; @@ -62,8 +67,12 @@ namespace osu.Game.Overlays.Comments AccentColour = borderContainer.BorderColour = sideNumber.Colour = colours.GreenLight; hoverLayer.Colour = Color4.Black.Opacity(0.5f); - if (api.IsLoggedIn && api.LocalUser.Value.Id != comment.UserId) + var ownComment = api.LocalUser.Value.Id == comment.UserId; + + if (!ownComment) Action = onAction; + + Background.Alpha = ownComment ? 0 : 1; } protected override void LoadComplete() @@ -71,12 +80,18 @@ namespace osu.Game.Overlays.Comments base.LoadComplete(); isVoted.Value = comment.IsVoted; votesCount.Value = comment.VotesCount; - isVoted.BindValueChanged(voted => background.Colour = voted.NewValue ? AccentColour : colourProvider.Background6, true); + isVoted.BindValueChanged(voted => Background.Colour = voted.NewValue ? AccentColour : colourProvider.Background6, true); votesCount.BindValueChanged(count => votesCounter.Text = $"+{count.NewValue}", true); } private void onAction() { + if (!api.IsLoggedIn) + { + login?.Show(); + return; + } + request = new CommentVoteRequest(comment.Id, isVoted.Value ? CommentVoteAction.UnVote : CommentVoteAction.Vote); request.Success += onSuccess; api.Queue(request); @@ -102,7 +117,7 @@ namespace osu.Game.Overlays.Comments Masking = true, Children = new Drawable[] { - background = new Box + Background = new Box { RelativeSizeAxes = Axes.Both }, diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index e6fe6ac749..0922ce5ecc 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -244,7 +244,7 @@ namespace osu.Game.Overlays.Dashboard.Friends return unsorted.OrderByDescending(u => u.LastVisit).ToList(); case UserSortCriteria.Rank: - return unsorted.OrderByDescending(u => u.CurrentModeRank.HasValue).ThenBy(u => u.CurrentModeRank ?? 0).ToList(); + return unsorted.OrderByDescending(u => u.Statistics.GlobalRank.HasValue).ThenBy(u => u.Statistics.GlobalRank ?? 0).ToList(); case UserSortCriteria.Username: return unsorted.OrderBy(u => u.Username).ToList(); diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index 03c320debe..83ad8faf1c 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -2,155 +2,35 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Threading; -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.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard.Friends; namespace osu.Game.Overlays { - public class DashboardOverlay : FullscreenOverlay + public class DashboardOverlay : TabbableOnlineOverlay { - private CancellationTokenSource cancellationToken; - - private Container content; - private LoadingLayer loading; - private OverlayScrollContainer scrollFlow; - public DashboardOverlay() - : base(OverlayColourScheme.Purple, new DashboardOverlayHeader - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Depth = -float.MaxValue - }) + : base(OverlayColourScheme.Purple) { } - private readonly IBindable apiState = new Bindable(); + protected override DashboardOverlayHeader CreateHeader() => new DashboardOverlayHeader(); - [BackgroundDependencyLoader] - private void load(IAPIProvider api) + protected override void CreateDisplayToLoad(DashboardOverlayTabs tab) { - apiState.BindTo(api.State); - apiState.BindValueChanged(onlineStateChanged, true); - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background5 - }, - scrollFlow = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header, - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - } - } - } - }, - loading = new LoadingLayer(true), - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Header.Current.BindValueChanged(onTabChanged); - } - - private bool displayUpdateRequired = true; - - protected override void PopIn() - { - base.PopIn(); - - // We don't want to create a new display on every call, only when exiting from fully closed state. - if (displayUpdateRequired) - { - Header.Current.TriggerChange(); - displayUpdateRequired = false; - } - } - - protected override void PopOutComplete() - { - base.PopOutComplete(); - loadDisplay(Empty()); - displayUpdateRequired = true; - } - - private void loadDisplay(Drawable display) - { - scrollFlow.ScrollToStart(); - - LoadComponentAsync(display, loaded => - { - if (API.IsLoggedIn) - loading.Hide(); - - content.Child = loaded; - }, (cancellationToken = new CancellationTokenSource()).Token); - } - - private void onTabChanged(ValueChangedEvent tab) - { - cancellationToken?.Cancel(); - loading.Show(); - - if (!API.IsLoggedIn) - { - loadDisplay(Empty()); - return; - } - - switch (tab.NewValue) + switch (tab) { case DashboardOverlayTabs.Friends: - loadDisplay(new FriendDisplay()); + LoadDisplay(new FriendDisplay()); break; case DashboardOverlayTabs.CurrentlyPlaying: - loadDisplay(new CurrentlyPlayingDisplay()); + LoadDisplay(new CurrentlyPlayingDisplay()); break; default: - throw new NotImplementedException($"Display for {tab.NewValue} tab is not implemented"); + throw new NotImplementedException($"Display for {tab} tab is not implemented"); } } - - private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => - { - if (State.Value == Visibility.Hidden) - return; - - Header.Current.TriggerChange(); - }); - - protected override void Dispose(bool isDisposing) - { - cancellationToken?.Cancel(); - base.Dispose(isDisposing); - } } } diff --git a/osu.Game/Overlays/Dialog/ConfirmDialog.cs b/osu.Game/Overlays/Dialog/ConfirmDialog.cs new file mode 100644 index 0000000000..a87c06ffdf --- /dev/null +++ b/osu.Game/Overlays/Dialog/ConfirmDialog.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// A dialog which confirms a user action. + /// + public class ConfirmDialog : PopupDialog + { + /// + /// Construct a new confirmation dialog. + /// + /// The description of the action to be displayed to the user. + /// An action to perform on confirmation. + /// An optional action to perform on cancel. + public ConfirmDialog(string message, Action onConfirm, Action onCancel = null) + { + HeaderText = message; + BodyText = "Last chance to turn back"; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Yes", + Action = onConfirm + }, + new PopupDialogCancelButton + { + Text = @"Cancel", + Action = onCancel + }, + }; + } + } +} diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 9f9dbdbaf1..4cc17a4c14 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -14,6 +14,9 @@ namespace osu.Game.Overlays { private readonly Container dialogContainer; + protected override string PopInSampleName => "UI/dialog-pop-in"; + protected override string PopOutSampleName => "UI/dialog-pop-out"; + public PopupDialog CurrentDialog { get; private set; } public DialogOverlay() diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 6f56d95929..735f0bcbd4 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.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 JetBrains.Annotations; using osu.Framework.Allocation; 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.Graphics.Containers; using osu.Game.Online.API; using osuTK.Graphics; @@ -15,21 +17,27 @@ namespace osu.Game.Overlays public abstract class FullscreenOverlay : WaveOverlayContainer, INamedOverlayComponent where T : OverlayHeader { - public virtual string IconTexture => Header?.Title.IconTexture ?? string.Empty; - public virtual string Title => Header?.Title.Title ?? string.Empty; - public virtual string Description => Header?.Title.Description ?? string.Empty; + public virtual string IconTexture => Header.Title.IconTexture ?? string.Empty; + public virtual string Title => Header.Title.Title ?? string.Empty; + public virtual string Description => Header.Title.Description ?? string.Empty; public T Header { get; } + protected virtual Color4 BackgroundColour => ColourProvider.Background5; + [Resolved] protected IAPIProvider API { get; private set; } [Cached] protected readonly OverlayColourProvider ColourProvider; - protected FullscreenOverlay(OverlayColourScheme colourScheme, T header) + protected override Container Content => content; + + private readonly Container content; + + protected FullscreenOverlay(OverlayColourScheme colourScheme) { - Header = header; + Header = CreateHeader(); ColourProvider = new OverlayColourProvider(colourScheme); @@ -47,6 +55,19 @@ namespace osu.Game.Overlays Type = EdgeEffectType.Shadow, Radius = 10 }; + + base.Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = BackgroundColour + }, + content = new Container + { + RelativeSizeAxes = Axes.Both + } + }); } [BackgroundDependencyLoader] @@ -58,6 +79,9 @@ namespace osu.Game.Overlays Waves.FourthWaveColour = ColourProvider.Dark3; } + [NotNull] + protected abstract T CreateHeader(); + public override void Show() { if (State.Value == Visibility.Visible) diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index bfabc8008d..0f9505beda 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -53,7 +53,7 @@ namespace osu.Game.Overlays.KeyBinding private FillFlowContainer cancelAndClearButtons; private FillFlowContainer buttons; - public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend((string)text.Text); + public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString()); public KeyBindingRow(object action, List bindings) { diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 4425c2f168..0feae16b68 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays private readonly Sprite innerSpin, outerSpin; private DrawableMedal drawableMedal; - private SampleChannel getSample; + private Sample getSample; private readonly Container content; diff --git a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs new file mode 100644 index 0000000000..78cd9bdae5 --- /dev/null +++ b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + public class LocalPlayerModSelectOverlay : ModSelectOverlay + { + protected override void OnModSelected(Mod mod) + { + base.OnModSelected(mod); + + foreach (var section in ModSectionsContainer.Children) + section.DeselectTypes(mod.IncompatibleMods, true); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index ab8efdabcc..5e3733cd5e 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -46,8 +46,9 @@ namespace osu.Game.Overlays.Mods /// Change the selected mod index of this button. /// /// The new index. + /// Whether any settings applied to the mod should be reset on selection. /// Whether the selection changed. - private bool changeSelectedIndex(int newIndex) + private bool changeSelectedIndex(int newIndex, bool resetSettings = true) { if (newIndex == selectedIndex) return false; @@ -69,6 +70,9 @@ namespace osu.Game.Overlays.Mods Mod newSelection = SelectedMod ?? Mods[0]; + if (resetSettings) + newSelection.ResetSettingsToDefaults(); + Schedule(() => { if (beforeSelected != Selected) @@ -209,11 +213,17 @@ namespace osu.Game.Overlays.Mods Deselect(); } - public bool SelectAt(int index) + /// + /// Select the mod at the provided index. + /// + /// The index to select. + /// Whether any settings applied to the mod should be reset on selection. + /// Whether the selection changed. + public bool SelectAt(int index, bool resetSettings = true) { if (!Mods[index].HasImplementation) return false; - changeSelectedIndex(index); + changeSelectedIndex(index, resetSettings); return true; } @@ -236,13 +246,13 @@ namespace osu.Game.Overlays.Mods { iconsContainer.AddRange(new[] { - backgroundIcon = new PassThroughTooltipModIcon(Mods[1]) + backgroundIcon = new ModIcon(Mods[1], false) { Origin = Anchor.BottomRight, Anchor = Anchor.BottomRight, Position = new Vector2(1.5f), }, - foregroundIcon = new PassThroughTooltipModIcon(Mods[0]) + foregroundIcon = new ModIcon(Mods[0], false) { Origin = Anchor.BottomRight, Anchor = Anchor.BottomRight, @@ -252,7 +262,7 @@ namespace osu.Game.Overlays.Mods } else { - iconsContainer.Add(foregroundIcon = new PassThroughTooltipModIcon(Mod) + iconsContainer.Add(foregroundIcon = new ModIcon(Mod, false) { Origin = Anchor.Centre, Anchor = Anchor.Centre, @@ -297,15 +307,5 @@ namespace osu.Game.Overlays.Mods Mod = mod; } - - private class PassThroughTooltipModIcon : ModIcon - { - public override string TooltipText => null; - - public PassThroughTooltipModIcon(Mod mod) - : base(mod) - { - } - } } } diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index 573d1e5355..aa8a5efd39 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -11,31 +11,32 @@ using System; using System.Linq; using System.Collections.Generic; using System.Threading; +using Humanizer; using osu.Framework.Input.Events; using osu.Game.Graphics; namespace osu.Game.Overlays.Mods { - public abstract class ModSection : Container + public class ModSection : CompositeDrawable { - private readonly OsuSpriteText headerLabel; + private readonly Drawable header; public FillFlowContainer ButtonsContainer { get; } + protected IReadOnlyList Buttons { get; private set; } = Array.Empty(); + public Action Action; - protected abstract Key[] ToggleKeys { get; } - public abstract ModType ModType { get; } - public string Header - { - get => headerLabel.Text; - set => headerLabel.Text = value; - } + public Key[] ToggleKeys; - public IEnumerable SelectedMods => buttons.Select(b => b.SelectedMod).Where(m => m != null); + public readonly ModType ModType; + + public IEnumerable SelectedMods => Buttons.Select(b => b.SelectedMod).Where(m => m != null); private CancellationTokenSource modsLoadCts; + protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0; + /// /// True when all mod icons have completed loading. /// @@ -52,7 +53,11 @@ namespace osu.Game.Overlays.Mods return new ModButton(m) { - SelectionChanged = Action, + SelectionChanged = mod => + { + ModButtonStateChanged(mod); + Action?.Invoke(mod); + }, }; }).ToArray(); @@ -61,7 +66,7 @@ namespace osu.Game.Overlays.Mods if (modContainers.Length == 0) { ModIconsLoaded = true; - headerLabel.Hide(); + header.Hide(); Hide(); return; } @@ -74,14 +79,16 @@ namespace osu.Game.Overlays.Mods ButtonsContainer.ChildrenEnumerable = c; }, (modsLoadCts = new CancellationTokenSource()).Token); - buttons = modContainers.OfType().ToArray(); + Buttons = modContainers.OfType().ToArray(); - headerLabel.FadeIn(200); + header.FadeIn(200); this.FadeIn(200); } } - private ModButton[] buttons = Array.Empty(); + protected virtual void ModButtonStateChanged(Mod mod) + { + } protected override bool OnKeyDown(KeyDownEvent e) { @@ -90,37 +97,82 @@ namespace osu.Game.Overlays.Mods if (ToggleKeys != null) { var index = Array.IndexOf(ToggleKeys, e.Key); - if (index > -1 && index < buttons.Length) - buttons[index].SelectNext(e.ShiftPressed ? -1 : 1); + if (index > -1 && index < Buttons.Count) + Buttons[index].SelectNext(e.ShiftPressed ? -1 : 1); } return base.OnKeyDown(e); } - public void DeselectAll() => DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); + private const double initial_multiple_selection_delay = 120; + + private double selectionDelay = initial_multiple_selection_delay; + private double lastSelection; + + private readonly Queue pendingSelectionOperations = new Queue(); + + protected override void Update() + { + base.Update(); + + if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay) + { + if (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) + { + dequeuedAction(); + + // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements). + selectionDelay = Math.Max(30, selectionDelay * 0.8f); + lastSelection = Time.Current; + } + else + { + // reset the selection delay after all animations have been completed. + // this will cause the next action to be immediately performed. + selectionDelay = initial_multiple_selection_delay; + } + } + } + + /// + /// Selects all mods. + /// + public void SelectAll() + { + pendingSelectionOperations.Clear(); + + foreach (var button in Buttons.Where(b => !b.Selected)) + pendingSelectionOperations.Enqueue(() => button.SelectAt(0)); + } + + /// + /// Deselects all mods. + /// + public void DeselectAll() + { + pendingSelectionOperations.Clear(); + DeselectTypes(Buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); + } /// /// Deselect one or more mods in this section. /// /// The types of s which should be deselected. - /// Set to true to bypass animations and update selections immediately. + /// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow. public void DeselectTypes(IEnumerable modTypes, bool immediate = false) { - int delay = 0; - - foreach (var button in buttons) + foreach (var button in Buttons) { - Mod selected = button.SelectedMod; - if (selected == null) continue; + if (button.SelectedMod == null) continue; foreach (var type in modTypes) { - if (type.IsInstanceOfType(selected)) + if (type.IsInstanceOfType(button.SelectedMod)) { if (immediate) button.Deselect(); else - Scheduler.AddDelayed(button.Deselect, delay += 50); + pendingSelectionOperations.Enqueue(button.Deselect); } } } @@ -130,13 +182,13 @@ namespace osu.Game.Overlays.Mods /// Updates all buttons with the given list of selected mods. /// /// The new list of selected mods to select. - public void UpdateSelectedMods(IReadOnlyList newSelectedMods) + public void UpdateSelectedButtons(IReadOnlyList newSelectedMods) { - foreach (var button in buttons) - updateButtonMods(button, newSelectedMods); + foreach (var button in Buttons) + updateButtonSelection(button, newSelectedMods); } - private void updateButtonMods(ModButton button, IReadOnlyList newSelectedMods) + private void updateButtonSelection(ModButton button, IReadOnlyList newSelectedMods) { foreach (var mod in newSelectedMods) { @@ -145,31 +197,30 @@ namespace osu.Game.Overlays.Mods continue; var buttonMod = button.Mods[index]; + + // as this is likely coming from an external change, ensure the settings of the mod are in sync. buttonMod.CopyFrom(mod); - button.SelectAt(index); + + button.SelectAt(index, false); return; } button.Deselect(); } - protected ModSection() + public ModSection(ModType type) { + ModType = type; + AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; Origin = Anchor.TopCentre; Anchor = Anchor.TopCentre; - Children = new Drawable[] + InternalChildren = new[] { - headerLabel = new OsuSpriteText - { - Origin = Anchor.TopLeft, - Anchor = Anchor.TopLeft, - Position = new Vector2(0f, 0f), - Font = OsuFont.GetFont(weight: FontWeight.Bold) - }, + header = CreateHeader(type.Humanize(LetterCasing.Title)), ButtonsContainer = new FillFlowContainer { AutoSizeAxes = Axes.Y, @@ -185,5 +236,20 @@ namespace osu.Game.Overlays.Mods }, }; } + + protected virtual Drawable CreateHeader(string text) => new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = text + }; + + /// + /// Play out all remaining animations immediately to leave mods in a good (final) state. + /// + public void FlushAnimation() + { + while (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) + dequeuedAction(); + } } } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index b93602116b..26b8632d7f 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -19,30 +20,59 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; -using osu.Game.Overlays.Mods.Sections; using osu.Game.Rulesets.Mods; using osu.Game.Screens; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Mods { - public class ModSelectOverlay : WaveOverlayContainer + public abstract class ModSelectOverlay : WaveOverlayContainer { - private readonly Func isValidMod; public const float HEIGHT = 510; protected readonly TriangleButton DeselectAllButton; protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CloseButton; + protected readonly Drawable MultiplierSection; protected readonly OsuSpriteText MultiplierLabel; + protected readonly FillFlowContainer FooterContainer; + protected override bool BlockNonPositionalInput => false; protected override bool DimMainContent => false; + /// + /// Whether s underneath the same instance should appear as stacked buttons. + /// + protected virtual bool Stacked => true; + + /// + /// Whether configurable s can be configured by the local user. + /// + protected virtual bool AllowConfiguration => true; + + [NotNull] + private Func isValidMod = m => true; + + /// + /// A function that checks whether a given mod is selectable. + /// + [NotNull] + public Func IsValidMod + { + get => isValidMod; + set + { + isValidMod = value ?? throw new ArgumentNullException(nameof(value)); + updateAvailableMods(); + } + } + protected readonly FillFlowContainer ModSectionsContainer; protected readonly ModSettingsContainer ModSettingsContainer; @@ -57,14 +87,10 @@ namespace osu.Game.Overlays.Mods private const float content_width = 0.8f; private const float footer_button_spacing = 20; - private readonly FillFlowContainer footerContainer; + private Sample sampleOn, sampleOff; - private SampleChannel sampleOn, sampleOff; - - public ModSelectOverlay(Func isValidMod = null) + protected ModSelectOverlay() { - this.isValidMod = isValidMod ?? (m => true); - Waves.FirstWaveColour = Color4Extensions.FromHex(@"19b0e2"); Waves.SecondWaveColour = Color4Extensions.FromHex(@"2280a2"); Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); @@ -190,13 +216,31 @@ namespace osu.Game.Overlays.Mods Width = content_width, LayoutDuration = 200, LayoutEasing = Easing.OutQuint, - Children = new ModSection[] + Children = new[] { - new DifficultyReductionSection { Action = modButtonPressed }, - new DifficultyIncreaseSection { Action = modButtonPressed }, - new AutomationSection { Action = modButtonPressed }, - new ConversionSection { Action = modButtonPressed }, - new FunSection { Action = modButtonPressed }, + CreateModSection(ModType.DifficultyReduction).With(s => + { + s.ToggleKeys = new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }; + s.Action = modButtonPressed; + }), + CreateModSection(ModType.DifficultyIncrease).With(s => + { + s.ToggleKeys = new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }; + s.Action = modButtonPressed; + }), + CreateModSection(ModType.Automation).With(s => + { + s.ToggleKeys = new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }; + s.Action = modButtonPressed; + }), + CreateModSection(ModType.Conversion).With(s => + { + s.Action = modButtonPressed; + }), + CreateModSection(ModType.Fun).With(s => + { + s.Action = modButtonPressed; + }), } }, } @@ -216,9 +260,9 @@ namespace osu.Game.Overlays.Mods }, new Drawable[] { - // Footer new Container { + Name = "Footer content", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Origin = Anchor.TopCentre, @@ -231,22 +275,21 @@ namespace osu.Game.Overlays.Mods Colour = new Color4(172, 20, 116, 255), Alpha = 0.5f, }, - footerContainer = new FillFlowContainer + FooterContainer = new FillFlowContainer { Origin = Anchor.BottomCentre, Anchor = Anchor.BottomCentre, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + RelativePositionAxes = Axes.X, Width = content_width, Spacing = new Vector2(footer_button_spacing, footer_button_spacing / 2), - LayoutDuration = 100, - LayoutEasing = Easing.OutQuint, Padding = new MarginPadding { Vertical = 15, Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] + Children = new[] { DeselectAllButton = new TriangleButton { @@ -262,6 +305,7 @@ namespace osu.Game.Overlays.Mods Text = "Customisation", Action = () => ModSettingsContainer.ToggleVisibility(), Enabled = { Value = false }, + Alpha = AllowConfiguration ? 1 : 0, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, @@ -273,7 +317,7 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, - new FillFlowContainer + MultiplierSection = new FillFlowContainer { AutoSizeAxes = Axes.Both, Spacing = new Vector2(footer_button_spacing / 2, 0), @@ -329,33 +373,28 @@ namespace osu.Game.Overlays.Mods refreshSelectedMods(); } - /// - /// Deselect one or more mods. - /// - /// The types of s which should be deselected. - /// Set to true to bypass animations and update selections immediately. - private void deselectTypes(Type[] modTypes, bool immediate = false) - { - if (modTypes.Length == 0) return; - - foreach (var section in ModSectionsContainer.Children) - section.DeselectTypes(modTypes, immediate); - } - protected override void LoadComplete() { base.LoadComplete(); - availableMods.BindValueChanged(availableModsChanged, true); - SelectedMods.BindValueChanged(selectedModsChanged, true); + availableMods.BindValueChanged(_ => updateAvailableMods(), true); + + // intentionally bound after the above line to avoid a potential update feedback cycle. + // i haven't actually observed this happening but as updateAvailableMods() changes the selection it is plausible. + SelectedMods.BindValueChanged(_ => updateSelectedButtons()); } protected override void PopOut() { base.PopOut(); - footerContainer.MoveToX(footerContainer.DrawSize.X, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - footerContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); + foreach (var section in ModSectionsContainer) + { + section.FlushAnimation(); + } + + FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); + FooterContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); foreach (var section in ModSectionsContainer.Children) { @@ -369,8 +408,8 @@ namespace osu.Game.Overlays.Mods { base.PopIn(); - footerContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); - footerContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); + FooterContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); + FooterContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); foreach (var section in ModSectionsContainer.Children) { @@ -401,23 +440,59 @@ namespace osu.Game.Overlays.Mods public override bool OnPressed(GlobalAction action) => false; // handled by back button - private void availableModsChanged(ValueChangedEvent>> mods) + private void updateAvailableMods() { - if (mods.NewValue == null) return; + if (availableMods?.Value == null) + return; foreach (var section in ModSectionsContainer.Children) - section.Mods = mods.NewValue[section.ModType].Where(isValidMod); + { + IEnumerable modEnumeration = availableMods.Value[section.ModType]; + + if (!Stacked) + modEnumeration = ModUtils.FlattenMods(modEnumeration); + + section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null); + } + + updateSelectedButtons(); + OnAvailableModsChanged(); } - private void selectedModsChanged(ValueChangedEvent> mods) + /// + /// Returns a valid form of a given if possible, or null otherwise. + /// + /// + /// This is a recursive process during which any invalid mods are culled while preserving structures where possible. + /// + /// The to check. + /// A valid form of if exists, or null otherwise. + [CanBeNull] + private Mod getValidModOrNull([NotNull] Mod mod) { - foreach (var section in ModSectionsContainer.Children) - section.UpdateSelectedMods(mods.NewValue); + if (!(mod is MultiMod multi)) + return IsValidMod(mod) ? mod : null; - updateMods(); + var validSubset = multi.Mods.Select(getValidModOrNull).Where(m => m != null).ToArray(); + + if (validSubset.Length == 0) + return null; + + return validSubset.Length == 1 ? validSubset[0] : new MultiMod(validSubset); } - private void updateMods() + private void updateSelectedButtons() + { + // Enumeration below may update the bindable list. + var selectedMods = SelectedMods.Value.ToList(); + + foreach (var section in ModSectionsContainer.Children) + section.UpdateSelectedButtons(selectedMods); + + updateMultiplier(); + } + + private void updateMultiplier() { var multiplier = 1.0; @@ -439,22 +514,50 @@ namespace osu.Game.Overlays.Mods { if (selectedMod != null) { - if (State.Value == Visibility.Visible) sampleOn?.Play(); + if (State.Value == Visibility.Visible) + Scheduler.AddOnce(playSelectedSound); - deselectTypes(selectedMod.IncompatibleMods, true); + OnModSelected(selectedMod); - if (selectedMod.RequiresConfiguration) ModSettingsContainer.Show(); + if (selectedMod.RequiresConfiguration && AllowConfiguration) + ModSettingsContainer.Show(); } else { - if (State.Value == Visibility.Visible) sampleOff?.Play(); + if (State.Value == Visibility.Visible) + Scheduler.AddOnce(playDeselectedSound); } refreshSelectedMods(); } + private void playSelectedSound() => sampleOn?.Play(); + private void playDeselectedSound() => sampleOff?.Play(); + + /// + /// Invoked after has changed. + /// + protected virtual void OnAvailableModsChanged() + { + } + + /// + /// Invoked when a new has been selected. + /// + /// The that has been selected. + protected virtual void OnModSelected(Mod mod) + { + } + private void refreshSelectedMods() => SelectedMods.Value = ModSectionsContainer.Children.SelectMany(s => s.SelectedMods).ToArray(); + /// + /// Creates a that groups s with the same . + /// + /// The of s in the section. + /// The . + protected virtual ModSection CreateModSection(ModType type) => new ModSection(type); + #region Disposal protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Overlays/Mods/ModSettingsContainer.cs b/osu.Game/Overlays/Mods/ModSettingsContainer.cs index 1c57ff54ad..64d65cab3b 100644 --- a/osu.Game/Overlays/Mods/ModSettingsContainer.cs +++ b/osu.Game/Overlays/Mods/ModSettingsContainer.cs @@ -52,6 +52,7 @@ namespace osu.Game.Overlays.Mods new OsuScrollContainer { RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, Child = modSettingsContent = new FillFlowContainer { Anchor = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Mods/Sections/AutomationSection.cs b/osu.Game/Overlays/Mods/Sections/AutomationSection.cs deleted file mode 100644 index a2d7fec15f..0000000000 --- a/osu.Game/Overlays/Mods/Sections/AutomationSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class AutomationSection : ModSection - { - protected override Key[] ToggleKeys => new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }; - public override ModType ModType => ModType.Automation; - - public AutomationSection() - { - Header = @"Automation"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/ConversionSection.cs b/osu.Game/Overlays/Mods/Sections/ConversionSection.cs deleted file mode 100644 index 24fd8c30dd..0000000000 --- a/osu.Game/Overlays/Mods/Sections/ConversionSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class ConversionSection : ModSection - { - protected override Key[] ToggleKeys => null; - public override ModType ModType => ModType.Conversion; - - public ConversionSection() - { - Header = @"Conversion"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs b/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs deleted file mode 100644 index 0b7ccd1f25..0000000000 --- a/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class DifficultyIncreaseSection : ModSection - { - protected override Key[] ToggleKeys => new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }; - public override ModType ModType => ModType.DifficultyIncrease; - - public DifficultyIncreaseSection() - { - Header = @"Difficulty Increase"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs b/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs deleted file mode 100644 index 508e92508b..0000000000 --- a/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class DifficultyReductionSection : ModSection - { - protected override Key[] ToggleKeys => new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }; - public override ModType ModType => ModType.DifficultyReduction; - - public DifficultyReductionSection() - { - Header = @"Difficulty Reduction"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/FunSection.cs b/osu.Game/Overlays/Mods/Sections/FunSection.cs deleted file mode 100644 index af1f5836b1..0000000000 --- a/osu.Game/Overlays/Mods/Sections/FunSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class FunSection : ModSection - { - protected override Key[] ToggleKeys => null; - public override ModType ModType => ModType.Fun; - - public FunSection() - { - Header = @"Fun"; - } - } -} diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 96dff39fae..571b14428e 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -48,8 +48,8 @@ namespace osu.Game.Overlays.Music artistColour = colours.Gray9; HandleColour = colours.Gray5; - title = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.TitleUnicode, Model.Metadata.Title))); - artist = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.ArtistUnicode, Model.Metadata.Artist))); + title = localisation.GetLocalisedString(new RomanisableString(Model.Metadata.TitleUnicode, Model.Metadata.Title)); + artist = localisation.GetLocalisedString(new RomanisableString(Model.Metadata.ArtistUnicode, Model.Metadata.Artist)); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index 5820d405d4..5beb285216 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -2,67 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading; -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.Graphics.UserInterface; using osu.Game.Overlays.News; using osu.Game.Overlays.News.Displays; namespace osu.Game.Overlays { - public class NewsOverlay : FullscreenOverlay + public class NewsOverlay : OnlineOverlay { private readonly Bindable article = new Bindable(null); - private Container content; - private LoadingLayer loading; - private OverlayScrollContainer scrollFlow; - public NewsOverlay() - : base(OverlayColourScheme.Purple, new NewsHeader()) + : base(OverlayColourScheme.Purple, false) { } - [BackgroundDependencyLoader] - private void load() - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background5, - }, - scrollFlow = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header.With(h => - { - h.ShowFrontPage = ShowFrontPage; - }), - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - }, - }, - }, - loading = new LoadingLayer(true), - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -71,6 +26,11 @@ namespace osu.Game.Overlays article.BindValueChanged(onArticleChanged); } + protected override NewsHeader CreateHeader() => new NewsHeader + { + ShowFrontPage = ShowFrontPage + }; + private bool displayUpdateRequired = true; protected override void PopIn() @@ -107,7 +67,7 @@ namespace osu.Game.Overlays private void onArticleChanged(ValueChangedEvent e) { cancellationToken?.Cancel(); - loading.Show(); + Loading.Show(); if (e.NewValue == null) { @@ -122,11 +82,11 @@ namespace osu.Game.Overlays protected void LoadDisplay(Drawable display) { - scrollFlow.ScrollToStart(); + ScrollFlow.ScrollToStart(); LoadComponentAsync(display, loaded => { - content.Child = loaded; - loading.Hide(); + Child = loaded; + Loading.Hide(); }, (cancellationToken = new CancellationTokenSource()).Token); } diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 2dc6b39a92..d1a97c74b2 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -3,6 +3,8 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -40,6 +42,11 @@ namespace osu.Game.Overlays.Notifications /// public virtual bool DisplayOnTop => true; + private Sample samplePopIn; + private Sample samplePopOut; + protected virtual string PopInSampleName => "UI/notification-pop-in"; + protected virtual string PopOutSampleName => "UI/overlay-pop-out"; // TODO: replace with a unique sample? + protected NotificationLight Light; private readonly CloseButton closeButton; protected Container IconContent; @@ -107,7 +114,7 @@ namespace osu.Game.Overlays.Notifications closeButton = new CloseButton { Alpha = 0, - Action = Close, + Action = () => Close(), Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding @@ -120,6 +127,13 @@ namespace osu.Game.Overlays.Notifications }); } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + samplePopIn = audio.Samples.Get(PopInSampleName); + samplePopOut = audio.Samples.Get(PopOutSampleName); + } + protected override bool OnHover(HoverEvent e) { closeButton.FadeIn(75); @@ -143,6 +157,9 @@ namespace osu.Game.Overlays.Notifications protected override void LoadComplete() { base.LoadComplete(); + + samplePopIn?.Play(); + this.FadeInFromZero(200); NotificationContent.MoveToX(DrawSize.X); NotificationContent.MoveToX(0, 500, Easing.OutQuint); @@ -150,12 +167,15 @@ namespace osu.Game.Overlays.Notifications public bool WasClosed; - public virtual void Close() + public virtual void Close(bool playSound = true) { if (WasClosed) return; WasClosed = true; + if (playSound) + samplePopOut?.Play(); + Closed?.Invoke(); this.FadeOut(100); Expire(); diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index c2a958b65e..2316199049 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -8,10 +8,11 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Notifications { @@ -37,7 +38,7 @@ namespace osu.Game.Overlays.Notifications public NotificationSection(string title, string clearButtonText) { - this.clearButtonText = clearButtonText; + this.clearButtonText = clearButtonText.ToUpperInvariant(); titleText = title; } @@ -109,14 +110,32 @@ namespace osu.Game.Overlays.Notifications private void clearAll() { - notifications.Children.ForEach(c => c.Close()); + bool first = true; + notifications.Children.ForEach(c => + { + c.Close(first); + first = false; + }); } protected override void Update() { base.Update(); - countDrawable.Text = notifications.Children.Count(c => c.Alpha > 0.99f).ToString(); + countDrawable.Text = getVisibleCount().ToString(); + } + + private int getVisibleCount() + { + int count = 0; + + foreach (var c in notifications) + { + if (c.Alpha > 0.99f) + count++; + } + + return count; } private class ClearAllButton : OsuClickableContainer @@ -133,10 +152,10 @@ namespace osu.Game.Overlays.Notifications }; } - public string Text + public LocalisableString Text { get => text.Text; - set => text.Text = value.ToUpperInvariant(); + set => text.Text = value; } } diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 3105ecd742..703c14af2b 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -150,12 +150,12 @@ namespace osu.Game.Overlays.Notifications colourCancelled = colours.Red; } - public override void Close() + public override void Close(bool playSound = true) { switch (State) { case ProgressNotificationState.Cancelled: - base.Close(); + base.Close(playSound); break; case ProgressNotificationState.Active: diff --git a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs new file mode 100644 index 0000000000..13c9c5a02d --- /dev/null +++ b/osu.Game/Overlays/Notifications/SimpleErrorNotification.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 osu.Framework.Graphics.Sprites; + +namespace osu.Game.Overlays.Notifications +{ + public class SimpleErrorNotification : SimpleNotification + { + protected override string PopInSampleName => "UI/error-notification-pop-in"; + + public SimpleErrorNotification() + { + Icon = FontAwesome.Solid.Bomb; + } + } +} diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 9beb859f28..81bf71cdec 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -51,6 +51,9 @@ namespace osu.Game.Overlays private Container dragContainer; private Container playerContainer; + protected override string PopInSampleName => "UI/now-playing-pop-in"; + protected override string PopOutSampleName => "UI/now-playing-pop-out"; + /// /// Provide a source for the toolbar height. /// @@ -84,11 +87,6 @@ namespace osu.Game.Overlays AutoSizeAxes = Axes.Y, Children = new Drawable[] { - playlist = new PlaylistOverlay - { - RelativeSizeAxes = Axes.X, - Y = player_height + 10, - }, playerContainer = new Container { RelativeSizeAxes = Axes.X, @@ -171,7 +169,7 @@ namespace osu.Game.Overlays Anchor = Anchor.CentreRight, Position = new Vector2(-bottom_black_area_height / 2, 0), Icon = FontAwesome.Solid.Bars, - Action = () => playlist.ToggleVisibility(), + Action = togglePlaylist }, } }, @@ -191,13 +189,35 @@ namespace osu.Game.Overlays }; } + private void togglePlaylist() + { + if (playlist == null) + { + LoadComponentAsync(playlist = new PlaylistOverlay + { + RelativeSizeAxes = Axes.X, + Y = player_height + 10, + }, _ => + { + dragContainer.Add(playlist); + + playlist.BeatmapSets.BindTo(musicController.BeatmapSets); + playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + + togglePlaylist(); + }); + + return; + } + + if (!beatmap.Disabled) + playlist.ToggleVisibility(); + } + protected override void LoadComplete() { base.LoadComplete(); - playlist.BeatmapSets.BindTo(musicController.BeatmapSets); - playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); - beatmap.BindDisabledChanged(beatmapDisabledChanged, true); musicController.TrackChanged += trackChanged; @@ -273,8 +293,8 @@ namespace osu.Game.Overlays else { BeatmapMetadata metadata = beatmap.Metadata; - title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); - artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); + title.Text = new RomanisableString(metadata.TitleUnicode, metadata.Title); + artist.Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); } }); @@ -306,7 +326,7 @@ namespace osu.Game.Overlays private void beatmapDisabledChanged(bool disabled) { if (disabled) - playlist.Hide(); + playlist?.Hide(); prevButton.Enabled.Value = !disabled; nextButton.Enabled.Value = !disabled; @@ -411,6 +431,11 @@ namespace osu.Game.Overlays private class HoverableProgressBar : ProgressBar { + public HoverableProgressBar() + : base(true) + { + } + protected override bool OnHover(HoverEvent e) { this.ResizeHeightTo(progress_height, 500, Easing.OutQuint); diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 8e8a99a0a7..51214fe460 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -3,6 +3,8 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -19,6 +21,13 @@ namespace osu.Game.Overlays.OSD { private const int lights_bottom_margin = 40; + private readonly int optionCount; + private readonly int selectedOption = -1; + + private Sample sampleOn; + private Sample sampleOff; + private Sample sampleChange; + public TrackedSettingToast(SettingDescription description) : base(description.Name, description.Value, description.Shortcut) { @@ -46,9 +55,6 @@ namespace osu.Game.Overlays.OSD } }; - int optionCount = 0; - int selectedOption = -1; - switch (description.RawValue) { case bool val: @@ -69,6 +75,34 @@ namespace osu.Game.Overlays.OSD optionLights.Add(new OptionLight { Glowing = i == selectedOption }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (optionCount == 1) + { + if (selectedOption == 0) + sampleOn?.Play(); + else + sampleOff?.Play(); + } + else + { + if (sampleChange == null) return; + + sampleChange.Frequency.Value = 1 + (double)selectedOption / (optionCount - 1) * 0.25f; + sampleChange.Play(); + } + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOn = audio.Samples.Get("UI/osd-on"); + sampleOff = audio.Samples.Get("UI/osd-off"); + sampleChange = audio.Samples.Get("UI/osd-change"); + } + private class OptionLight : Container { private Color4 glowingColour, idleColour; diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs new file mode 100644 index 0000000000..de33e4a1bc --- /dev/null +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; + +namespace osu.Game.Overlays +{ + public abstract class OnlineOverlay : FullscreenOverlay + where T : OverlayHeader + { + protected override Container Content => content; + + protected readonly OverlayScrollContainer ScrollFlow; + protected readonly LoadingLayer Loading; + private readonly Container content; + + protected OnlineOverlay(OverlayColourScheme colourScheme, bool requiresSignIn = true) + : base(colourScheme) + { + var mainContent = requiresSignIn + ? new OnlineViewContainer($"Sign in to view the {Header.Title.Title}") + : new Container(); + + mainContent.RelativeSizeAxes = Axes.Both; + + mainContent.AddRange(new Drawable[] + { + ScrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header.With(h => h.Depth = float.MinValue), + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + Loading = new LoadingLayer(true) + }); + + base.Content.Add(mainContent); + } + } +} diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index b67d5db1a4..0004719b87 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -17,9 +17,9 @@ using osuTK.Graphics; namespace osu.Game.Overlays { /// - /// which provides . Mostly used in . + /// which provides . Mostly used in . /// - public class OverlayScrollContainer : OsuScrollContainer + public class OverlayScrollContainer : UserTrackingScrollContainer { /// /// Scroll position at which the will be shown. diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index b2212336ef..0ebabd424f 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -17,6 +17,7 @@ using osu.Game.Overlays.Comments; using JetBrains.Annotations; using System; using osu.Framework.Extensions; +using osu.Framework.Localisation; namespace osu.Game.Overlays { @@ -30,7 +31,7 @@ namespace osu.Game.Overlays set => current.Current = value; } - public string Title + public LocalisableString Title { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index 2925107766..662f55317b 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -138,7 +138,6 @@ namespace osu.Game.Overlays.Profile.Header if (!string.IsNullOrEmpty(user.Twitter)) anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Twitter, "@" + user.Twitter, $@"https://twitter.com/{user.Twitter}"); anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Discord, user.Discord); - anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Skype, user.Skype, @"skype:" + user.Skype + @"?chat"); anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Link, websiteWithoutProtocol, user.Website); // If no information was added to the bottomLinkContainer, hide it to avoid unwanted padding diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 658cdb8ce3..62ebee7677 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -49,9 +49,12 @@ namespace osu.Game.Overlays.Profile.Header Spacing = new Vector2(10, 0), Children = new Drawable[] { - new AddFriendButton + new FollowersButton + { + User = { BindTarget = User } + }, + new MappingSubscribersButton { - RelativeSizeAxes = Axes.Y, User = { BindTarget = User } }, new MessageUserButton @@ -69,7 +72,6 @@ namespace osu.Game.Overlays.Profile.Header Width = UserProfileOverlay.CONTENT_X_MARGIN, Child = new ExpandDetailsButton { - RelativeSizeAxes = Axes.Y, Anchor = Anchor.Centre, Origin = Anchor.Centre, DetailsVisible = { BindTarget = DetailsVisible } @@ -142,8 +144,8 @@ namespace osu.Game.Overlays.Profile.Header private void updateDisplay(User user) { - hiddenDetailGlobal.Content = user?.Statistics?.Ranks.Global?.ToString("\\##,##0") ?? "-"; - hiddenDetailCountry.Content = user?.Statistics?.Ranks.Country?.ToString("\\##,##0") ?? "-"; + hiddenDetailGlobal.Content = user?.Statistics?.GlobalRank?.ToString("\\##,##0") ?? "-"; + hiddenDetailCountry.Content = user?.Statistics?.CountryRank?.ToString("\\##,##0") ?? "-"; } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs b/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs deleted file mode 100644 index 6c2b2dc16a..0000000000 --- a/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -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.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Users; -using osuTK; - -namespace osu.Game.Overlays.Profile.Header.Components -{ - public class AddFriendButton : ProfileHeaderButton - { - public readonly Bindable User = new Bindable(); - - public override string TooltipText => "friends"; - - private OsuSpriteText followerText; - - [BackgroundDependencyLoader] - private void load() - { - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Right = 10 }, - Children = new Drawable[] - { - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.User, - FillMode = FillMode.Fit, - Size = new Vector2(50, 14) - }, - followerText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(weight: FontWeight.Bold) - } - } - }; - - // todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly. - - User.BindValueChanged(user => updateFollowers(user.NewValue), true); - } - - private void updateFollowers(User user) => followerText.Text = user?.FollowerCount.ToString("#,##0"); - } -} diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs new file mode 100644 index 0000000000..bd8aa7b3bd --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Game.Users; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public class FollowersButton : ProfileHeaderStatisticsButton + { + public readonly Bindable User = new Bindable(); + + public override string TooltipText => "followers"; + + protected override IconUsage Icon => FontAwesome.Solid.User; + + [BackgroundDependencyLoader] + private void load() + { + // todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly. + User.BindValueChanged(user => SetValue(user.NewValue?.FollowerCount ?? 0), true); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs new file mode 100644 index 0000000000..b4d7c9a05c --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Game.Users; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public class MappingSubscribersButton : ProfileHeaderStatisticsButton + { + public readonly Bindable User = new Bindable(); + + public override string TooltipText => "mapping subscribers"; + + protected override IconUsage Icon => FontAwesome.Solid.Bell; + + [BackgroundDependencyLoader] + private void load() + { + User.BindValueChanged(user => SetValue(user.NewValue?.MappingFollowerCount ?? 0), true); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs index cc6edcdd6a..228765ee1a 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs @@ -33,7 +33,6 @@ namespace osu.Game.Overlays.Profile.Header.Components public MessageUserButton() { Content.Alpha = 0; - RelativeSizeAxes = Axes.Y; Child = new SpriteIcon { diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs index e14d73dd98..cea63574cf 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs @@ -22,6 +22,7 @@ namespace osu.Game.Overlays.Profile.Header.Components protected ProfileHeaderButton() { AutoSizeAxes = Axes.X; + Height = 40; base.Content.Add(new CircularContainer { diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs new file mode 100644 index 0000000000..b65d5e2329 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public abstract class ProfileHeaderStatisticsButton : ProfileHeaderButton + { + private readonly OsuSpriteText drawableText; + + protected ProfileHeaderStatisticsButton() + { + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = Icon, + FillMode = FillMode.Fit, + Size = new Vector2(50, 14) + }, + drawableText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 10 }, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold) + } + } + }; + } + + protected abstract IconUsage Icon { get; } + + protected void SetValue(int value) => drawableText.Text = value.ToString("#,##0"); + } +} diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index cf6ae1a3fc..574aef02fd 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -176,8 +176,8 @@ namespace osu.Game.Overlays.Profile.Header foreach (var scoreRankInfo in scoreRankInfos) scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; - detailGlobalRank.Content = user?.Statistics?.Ranks.Global?.ToString("\\##,##0") ?? "-"; - detailCountryRank.Content = user?.Statistics?.Ranks.Country?.ToString("\\##,##0") ?? "-"; + detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToString("\\##,##0") ?? "-"; + detailCountryRank.Content = user?.Statistics?.CountryRank?.ToString("\\##,##0") ?? "-"; rankGraph.Statistics.Value = user?.Statistics; } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 5b7c5efbe2..20e40569e8 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -5,7 +5,6 @@ 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; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -13,6 +12,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Historical { @@ -129,14 +129,14 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { new OsuSpriteText { - Text = new LocalisedString(( + Text = new RomanisableString( $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} [{beatmap.Version}] ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] ")), + $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] "), Font = OsuFont.GetFont(weight: FontWeight.Bold) }, new OsuSpriteText { - Text = "by " + new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Text = "by " + new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Regular) }, }; diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index d4d0976724..cdb24b784c 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -23,51 +23,24 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu { this.user.BindTo(user); CountSection total; - CountSection avaliable; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 3; - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new[] - { - total = new CountTotal(), - avaliable = new CountAvailable() - } - } - }; - this.user.ValueChanged += u => - { - total.Count = u.NewValue?.Kudosu.Total ?? 0; - avaliable.Count = u.NewValue?.Kudosu.Available ?? 0; - }; + Child = total = new CountTotal(); + + this.user.ValueChanged += u => total.Count = u.NewValue?.Kudosu.Total ?? 0; } protected override bool OnClick(ClickEvent e) => true; - private class CountAvailable : CountSection - { - public CountAvailable() - : base("Kudosu Avaliable") - { - DescriptionText.Text = "Kudosu can be traded for kudosu stars, which will help your beatmap get more attention. This is the number of kudosu you haven't traded in yet."; - } - } - private class CountTotal : CountSection { public CountTotal() : base("Total Kudosu Earned") { DescriptionText.AddText("Based on how much of a contribution the user has made to beatmap moderation. See "); - DescriptionText.AddLink("this link", "https://osu.ppy.sh/wiki/Kudosu"); + DescriptionText.AddLink("this page", "https://osu.ppy.sh/wiki/Kudosu"); DescriptionText.AddText(" for more information."); } } @@ -80,13 +53,12 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu public new int Count { - set => valueText.Text = value.ToString(); + set => valueText.Text = value.ToString("N0"); } public CountSection(string header) { RelativeSizeAxes = Axes.X; - Width = 0.5f; AutoSizeAxes = Axes.Y; Padding = new MarginPadding { Top = 10, Bottom = 20 }; Child = new FillFlowContainer @@ -131,7 +103,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu private void load(OverlayColourProvider colourProvider) { lineBackground.Colour = colourProvider.Highlight1; - DescriptionText.Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 2c20dcc0ef..713303285a 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -256,16 +256,16 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = new LocalisedString(( + Text = new RomanisableString( $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} ")), + $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} "), Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) }, new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "by " + new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Text = "by " + new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), Font = OsuFont.GetFont(size: 12, italics: true) }, }; diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index 25350e310a..a093969115 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -4,96 +4,32 @@ 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.Overlays.Rankings; using osu.Game.Users; using osu.Game.Rulesets; using osu.Game.Online.API; -using System.Threading; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Rankings.Tables; namespace osu.Game.Overlays { - public class RankingsOverlay : FullscreenOverlay + public class RankingsOverlay : TabbableOnlineOverlay { protected Bindable Country => Header.Country; - protected Bindable Scope => Header.Current; - - private readonly OverlayScrollContainer scrollFlow; - private readonly Container contentContainer; - private readonly LoadingLayer loading; - private readonly Box background; - private APIRequest lastRequest; - private CancellationTokenSource cancellationToken; [Resolved] private IAPIProvider api { get; set; } - public RankingsOverlay() - : base(OverlayColourScheme.Green, new RankingsOverlayHeader - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Depth = -float.MaxValue - }) - { - loading = new LoadingLayer(true); - - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - scrollFlow = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - contentContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Bottom = 10 } - }, - } - } - } - } - }, - loading - }; - } - - [BackgroundDependencyLoader] - private void load() - { - background.Colour = ColourProvider.Background5; - } - [Resolved] private Bindable ruleset { get; set; } + public RankingsOverlay() + : base(OverlayColourScheme.Green) + { + } + protected override void LoadComplete() { base.LoadComplete(); @@ -104,31 +40,33 @@ namespace osu.Game.Overlays { // if a country is requested, force performance scope. if (Country.Value != null) - Scope.Value = RankingsScope.Performance; + Header.Current.Value = RankingsScope.Performance; - Scheduler.AddOnce(loadNewContent); - }); - - Scope.BindValueChanged(_ => - { - // country filtering is only valid for performance scope. - if (Scope.Value != RankingsScope.Performance) - Country.Value = null; - - Scheduler.AddOnce(loadNewContent); + Scheduler.AddOnce(triggerTabChanged); }); ruleset.BindValueChanged(_ => { - if (Scope.Value == RankingsScope.Spotlights) + if (Header.Current.Value == RankingsScope.Spotlights) return; - Scheduler.AddOnce(loadNewContent); + Scheduler.AddOnce(triggerTabChanged); }); - - Scheduler.AddOnce(loadNewContent); } + protected override void OnTabChanged(RankingsScope tab) + { + // country filtering is only valid for performance scope. + if (Header.Current.Value != RankingsScope.Performance) + Country.Value = null; + + Scheduler.AddOnce(triggerTabChanged); + } + + private void triggerTabChanged() => base.OnTabChanged(Header.Current.Value); + + protected override RankingsOverlayHeader CreateHeader() => new RankingsOverlayHeader(); + public void ShowCountry(Country requested) { if (requested == null) @@ -139,22 +77,13 @@ namespace osu.Game.Overlays Country.Value = requested; } - public void ShowSpotlights() + protected override void CreateDisplayToLoad(RankingsScope tab) { - Scope.Value = RankingsScope.Spotlights; - Show(); - } - - private void loadNewContent() - { - loading.Show(); - - cancellationToken?.Cancel(); lastRequest?.Cancel(); - if (Scope.Value == RankingsScope.Spotlights) + if (Header.Current.Value == RankingsScope.Spotlights) { - loadContent(new SpotlightsLayout + LoadDisplay(new SpotlightsLayout { Ruleset = { BindTarget = ruleset } }); @@ -166,19 +95,19 @@ namespace osu.Game.Overlays if (request == null) { - loadContent(null); + LoadDisplay(Empty()); return; } - request.Success += () => Schedule(() => loadContent(createTableFromResponse(request))); - request.Failure += _ => Schedule(() => loadContent(null)); + request.Success += () => Schedule(() => LoadDisplay(createTableFromResponse(request))); + request.Failure += _ => Schedule(() => LoadDisplay(Empty())); api.Queue(request); } private APIRequest createScopedRequest() { - switch (Scope.Value) + switch (Header.Current.Value) { case RankingsScope.Performance: return new GetUserRankingsRequest(ruleset.Value, country: Country.Value?.FlagName); @@ -216,29 +145,9 @@ namespace osu.Game.Overlays return null; } - private void loadContent(Drawable content) - { - scrollFlow.ScrollToStart(); - - if (content == null) - { - contentContainer.Clear(); - loading.Hide(); - return; - } - - LoadComponentAsync(content, loaded => - { - loading.Hide(); - contentContainer.Child = loaded; - }, (cancellationToken = new CancellationTokenSource()).Token); - } - protected override void Dispose(bool isDisposing) { lastRequest?.Cancel(); - cancellationToken?.Cancel(); - base.Dispose(isDisposing); } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index bed74542c9..b31e7dc45b 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Audio; using osu.Framework.Graphics; using System.Collections.Generic; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -76,7 +77,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio private class AudioDeviceDropdownControl : DropdownControl { - protected override string GenerateItemText(string item) + protected override LocalisableString GenerateItemText(string item) => string.IsNullOrEmpty(item) ? "Default" : base.GenerateItemText(item); } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 7acbf038d8..4d5c2e06eb 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -11,6 +11,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics.Containers; @@ -234,7 +235,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private class ResolutionDropdownControl : DropdownControl { - protected override string GenerateItemText(Size item) + protected override LocalisableString GenerateItemText(Size item) { if (item == new Size(9999, 9999)) return "Default"; diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs similarity index 70% rename from osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs rename to osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index db6f24a954..79c73863cf 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -5,17 +5,17 @@ using osu.Framework.Graphics; namespace osu.Game.Overlays.Settings.Sections.Input { - public class KeyboardSettings : SettingsSubsection + public class BindingSettings : SettingsSubsection { - protected override string Header => "Keyboard"; + protected override string Header => "Shortcut and gameplay bindings"; - public KeyboardSettings(KeyBindingPanel keyConfig) + public BindingSettings(KeyBindingPanel keyConfig) { Children = new Drawable[] { new SettingsButton { - Text = "Key configuration", + Text = "Configure", TooltipText = "change global shortcut keys and gameplay bindings", Action = keyConfig.ToggleVisibility }, diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 455e13711d..fb908a7669 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -1,11 +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.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Input.Handlers.Mouse; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -14,41 +14,44 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public class MouseSettings : SettingsSubsection { + private readonly MouseHandler mouseHandler; + protected override string Header => "Mouse"; - private readonly BindableBool rawInputToggle = new BindableBool(); - private Bindable sensitivityBindable = new BindableDouble(); - private Bindable ignoredInputHandlers; + private Bindable handlerSensitivity; + + private Bindable localSensitivity; private Bindable windowMode; private SettingsEnumDropdown confineMouseModeSetting; + private Bindable relativeMode; + + public MouseSettings(MouseHandler mouseHandler) + { + this.mouseHandler = mouseHandler; + } [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig, FrameworkConfigManager config) { - var configSensitivity = config.GetBindable(FrameworkSetting.CursorSensitivity); - // use local bindable to avoid changing enabled state of game host's bindable. - sensitivityBindable = configSensitivity.GetUnboundCopy(); - configSensitivity.BindValueChanged(val => sensitivityBindable.Value = val.NewValue); - sensitivityBindable.BindValueChanged(val => configSensitivity.Value = val.NewValue); + handlerSensitivity = mouseHandler.Sensitivity.GetBoundCopy(); + localSensitivity = handlerSensitivity.GetUnboundCopy(); + + relativeMode = mouseHandler.UseRelativeMode.GetBoundCopy(); + windowMode = config.GetBindable(FrameworkSetting.WindowMode); Children = new Drawable[] { new SettingsCheckbox { - LabelText = "Raw input", - Current = rawInputToggle + LabelText = "High precision mouse", + Current = relativeMode }, new SensitivitySetting { LabelText = "Cursor sensitivity", - Current = sensitivityBindable - }, - new SettingsCheckbox - { - LabelText = "Map absolute input to window", - Current = config.GetBindable(FrameworkSetting.MapAbsoluteInputToWindow) + Current = localSensitivity }, confineMouseModeSetting = new SettingsEnumDropdown { @@ -66,36 +69,40 @@ namespace osu.Game.Overlays.Settings.Sections.Input Current = osuConfig.GetBindable(OsuSetting.MouseDisableButtons) }, }; + } - windowMode = config.GetBindable(FrameworkSetting.WindowMode); - windowMode.BindValueChanged(mode => confineMouseModeSetting.Alpha = mode.NewValue == WindowMode.Fullscreen ? 0 : 1, true); + protected override void LoadComplete() + { + base.LoadComplete(); - if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + relativeMode.BindValueChanged(relative => localSensitivity.Disabled = !relative.NewValue, true); + + handlerSensitivity.BindValueChanged(val => { - rawInputToggle.Disabled = true; - sensitivityBindable.Disabled = true; - } - else + var disabled = localSensitivity.Disabled; + + localSensitivity.Disabled = false; + localSensitivity.Value = val.NewValue; + localSensitivity.Disabled = disabled; + }, true); + + localSensitivity.BindValueChanged(val => handlerSensitivity.Value = val.NewValue); + + windowMode.BindValueChanged(mode => { - rawInputToggle.ValueChanged += enabled => + var isFullscreen = mode.NewValue == WindowMode.Fullscreen; + + if (isFullscreen) { - // this is temporary until we support per-handler settings. - const string raw_mouse_handler = @"OsuTKRawMouseHandler"; - const string standard_mouse_handlers = @"OsuTKMouseHandler MouseHandler"; - - ignoredInputHandlers.Value = enabled.NewValue ? standard_mouse_handlers : raw_mouse_handler; - }; - - ignoredInputHandlers = config.GetBindable(FrameworkSetting.IgnoredInputHandlers); - ignoredInputHandlers.ValueChanged += handler => + confineMouseModeSetting.Current.Disabled = true; + confineMouseModeSetting.TooltipText = "Not applicable in full screen mode"; + } + else { - bool raw = !handler.NewValue.Contains("Raw"); - rawInputToggle.Value = raw; - sensitivityBindable.Disabled = !raw; - }; - - ignoredInputHandlers.TriggerChange(); - } + confineMouseModeSetting.Current.Disabled = false; + confineMouseModeSetting.TooltipText = string.Empty; + } + }, true); } private class SensitivitySetting : SettingsSlider @@ -109,7 +116,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override string TooltipText => Current.Disabled ? "enable raw input to adjust sensitivity" : $"{base.TooltipText}x"; + public override string TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index b43453f53d..8d5944f5bf 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -1,28 +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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Handlers; +using osu.Framework.Input.Handlers.Joystick; +using osu.Framework.Input.Handlers.Midi; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Platform; using osu.Game.Overlays.Settings.Sections.Input; namespace osu.Game.Overlays.Settings.Sections { public class InputSection : SettingsSection { + private readonly KeyBindingPanel keyConfig; + public override string Header => "Input"; + [Resolved] + private GameHost host { get; set; } + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.Keyboard }; public InputSection(KeyBindingPanel keyConfig) + { + this.keyConfig = keyConfig; + } + + [BackgroundDependencyLoader] + private void load() { Children = new Drawable[] { - new MouseSettings(), - new KeyboardSettings(keyConfig), + new BindingSettings(keyConfig), }; + + foreach (var handler in host.AvailableInputHandlers) + { + var handlerSection = createSectionFor(handler); + + if (handlerSection != null) + Add(handlerSection); + } + } + + private SettingsSubsection createSectionFor(InputHandler handler) + { + SettingsSubsection section; + + switch (handler) + { + case MouseHandler mh: + section = new MouseSettings(mh); + break; + + // whitelist the handlers which should be displayed to avoid any weird cases of users touching settings they shouldn't. + case JoystickHandler _: + case MidiHandler _: + section = new HandlerSection(handler); + break; + + default: + return null; + } + + return section; + } + + private class HandlerSection : SettingsSubsection + { + private readonly InputHandler handler; + + public HandlerSection(InputHandler handler) + { + this.handler = handler; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "Enabled", + Current = handler.Enabled + }, + }; + } + + protected override string Header => handler.Description; } } } diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 4cfd801caf..316837d27d 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -108,7 +109,7 @@ namespace osu.Game.Overlays.Settings.Sections if (skinDropdown.Items.All(s => s.ID != configBindable.Value)) configBindable.Value = 0; - configBindable.BindValueChanged(id => dropdownBindable.Value = skinDropdown.Items.Single(s => s.ID == id.NewValue), true); + configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true); dropdownBindable.BindValueChanged(skin => { if (skin.NewValue == random_skin_info) @@ -121,6 +122,23 @@ namespace osu.Game.Overlays.Settings.Sections }); } + private void updateSelectedSkinFromConfig() + { + int id = configBindable.Value; + + var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id); + + 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; + } + private void updateItems() { skinItems = skins.GetAllUsableSkins(); @@ -132,14 +150,14 @@ namespace osu.Game.Overlays.Settings.Sections private void itemUpdated(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) - { - Schedule(() => - { - List newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList(); - sortUserSkins(newDropdownItems); - skinDropdown.Items = newDropdownItems; - }); - } + 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(ValueChangedEvent> weakItem) @@ -161,7 +179,7 @@ namespace osu.Game.Overlays.Settings.Sections private class SkinDropdownControl : DropdownControl { - protected override string GenerateItemText(SkinInfo item) => item.ToString(); + protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString(); } } diff --git a/osu.Game/Overlays/Settings/SettingsCheckbox.cs b/osu.Game/Overlays/Settings/SettingsCheckbox.cs index 437b2e45b3..8b7ac80a5b 100644 --- a/osu.Game/Overlays/Settings/SettingsCheckbox.cs +++ b/osu.Game/Overlays/Settings/SettingsCheckbox.cs @@ -2,20 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { public class SettingsCheckbox : SettingsItem { - private string labelText; + private LocalisableString labelText; protected override Drawable CreateControl() => new OsuCheckbox(); - public override string LabelText + public override LocalisableString LabelText { get => labelText; - set => ((OsuCheckbox)Control).LabelText = labelText = value; + // checkbox doesn't properly support localisation yet. + set => ((OsuCheckbox)Control).LabelText = (labelText = value).ToString(); } } } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 278479e04f..85765bf991 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -15,13 +15,14 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; namespace osu.Game.Overlays.Settings { - public abstract class SettingsItem : Container, IFilterable, ISettingsItem, IHasCurrentValue + public abstract class SettingsItem : Container, IFilterable, ISettingsItem, IHasCurrentValue, IHasTooltip { protected abstract Drawable CreateControl(); @@ -37,7 +38,9 @@ namespace osu.Game.Overlays.Settings public bool ShowsDefaultIndicator = true; - public virtual string LabelText + public string TooltipText { get; set; } + + public virtual LocalisableString LabelText { get => labelText?.Text ?? string.Empty; set @@ -67,7 +70,7 @@ namespace osu.Game.Overlays.Settings set => controlWithCurrent.Current = value; } - public virtual IEnumerable FilterTerms => Keywords == null ? new[] { LabelText } : new List(Keywords) { LabelText }.ToArray(); + public virtual IEnumerable FilterTerms => Keywords == null ? new[] { LabelText.ToString() } : new List(Keywords) { LabelText.ToString() }.ToArray(); public IEnumerable Keywords { get; set; } @@ -118,8 +121,10 @@ namespace osu.Game.Overlays.Settings labelText.Alpha = controlWithCurrent.Current.Disabled ? 0.3f : 1; } - private class RestoreDefaultValueButton : Container, IHasTooltip + protected internal class RestoreDefaultValueButton : Container, IHasTooltip { + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + private Bindable bindable; public Bindable Bindable @@ -204,7 +209,9 @@ namespace osu.Game.Overlays.Settings UpdateState(); } - public void UpdateState() + public void UpdateState() => Scheduler.AddOnce(updateState); + + private void updateState() { if (bindable == null) return; diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 7a5a586f67..f1270f750e 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -40,6 +40,8 @@ namespace osu.Game.Overlays private SeekLimitedSearchTextBox searchTextBox; + protected override string PopInSampleName => "UI/settings-pop-in"; + /// /// Provide a source for the toolbar height. /// diff --git a/osu.Game/Overlays/TabbableOnlineOverlay.cs b/osu.Game/Overlays/TabbableOnlineOverlay.cs new file mode 100644 index 0000000000..9ceab12d3d --- /dev/null +++ b/osu.Game/Overlays/TabbableOnlineOverlay.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.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; + +namespace osu.Game.Overlays +{ + public abstract class TabbableOnlineOverlay : OnlineOverlay + where THeader : TabControlOverlayHeader + { + private readonly IBindable apiState = new Bindable(); + + private CancellationTokenSource cancellationToken; + private bool displayUpdateRequired = true; + + protected TabbableOnlineOverlay(OverlayColourScheme colourScheme) + : base(colourScheme) + { + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Header.Current.BindValueChanged(tab => OnTabChanged(tab.NewValue)); + } + + protected override void PopIn() + { + base.PopIn(); + + // We don't want to create a new display on every call, only when exiting from fully closed state. + if (displayUpdateRequired) + { + Header.Current.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + LoadDisplay(Empty()); + displayUpdateRequired = true; + } + + protected void LoadDisplay(Drawable display) + { + ScrollFlow.ScrollToStart(); + + LoadComponentAsync(display, loaded => + { + Loading.Hide(); + + Child = loaded; + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected virtual void OnTabChanged(TEnum tab) + { + cancellationToken?.Cancel(); + Loading.Show(); + + if (!API.IsLoggedIn) + { + LoadDisplay(Empty()); + return; + } + + CreateDisplayToLoad(tab); + } + + protected abstract void CreateDisplayToLoad(TEnum tab); + + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => + { + if (State.Value == Visibility.Hidden) + return; + + Header.Current.TriggerChange(); + }); + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index 393e349bd0..d049c2d3ec 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -13,14 +13,22 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Input.Events; using osu.Game.Rulesets; +using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class Toolbar : VisibilityContainer + public class Toolbar : VisibilityContainer, IKeyBindingHandler { public const float HEIGHT = 40; public const float TOOLTIP_HEIGHT = 30; + /// + /// Whether the user hid this with . + /// In this state, automatic toggles should not occur, respecting the user's preference to have no toolbar. + /// + private bool hiddenByUser; + public Action OnHome; private ToolbarUserButton userButton; @@ -30,13 +38,22 @@ namespace osu.Game.Overlays.Toolbar protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); - // Toolbar components like RulesetSelector should receive keyboard input events even when the toolbar is hidden. + // Toolbar and its components need keyboard input even when hidden. public override bool PropagateNonPositionalInputSubTree => true; public Toolbar() { RelativeSizeAxes = Axes.X; Size = new Vector2(1, HEIGHT); + AlwaysPresent = true; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // this only needed to be set for the initial LoadComplete/Update, so layout completes and gets buttons in a state they can correctly handle keyboard input for hotkeys. + AlwaysPresent = false; } [BackgroundDependencyLoader(true)] @@ -133,7 +150,9 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateState(ValueChangedEvent state) { - if (state.NewValue == Visibility.Visible && OverlayActivationMode.Value == OverlayActivation.Disabled) + bool blockShow = hiddenByUser || OverlayActivationMode.Value == OverlayActivation.Disabled; + + if (state.NewValue == Visibility.Visible && blockShow) { State.Value = Visibility.Hidden; return; @@ -155,5 +174,25 @@ namespace osu.Game.Overlays.Toolbar this.MoveToY(-DrawSize.Y, transition_time, Easing.OutQuint); this.FadeOut(transition_time, Easing.InQuint); } + + public bool OnPressed(GlobalAction action) + { + if (OverlayActivationMode.Value == OverlayActivation.Disabled) + return false; + + switch (action) + { + case GlobalAction.ToggleToolbar: + hiddenByUser = State.Value == Visibility.Visible; // set before toggling to allow the operation to always succeed. + ToggleVisibility(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 17bc913b43..432c52c2e9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -4,6 +4,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -13,6 +14,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Database; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; @@ -43,19 +45,19 @@ namespace osu.Game.Overlays.Toolbar Texture = textures.Get(texture), }); - public string Text + public LocalisableString Text { get => DrawableText.Text; set => DrawableText.Text = value; } - public string TooltipMain + public LocalisableString TooltipMain { get => tooltip1.Text; set => tooltip1.Text = value; } - public string TooltipSub + public LocalisableString TooltipSub { get => tooltip2.Text; set => tooltip2.Text = value; @@ -77,7 +79,7 @@ namespace osu.Game.Overlays.Toolbar private RealmContextFactory realmFactory { get; set; } protected ToolbarButton() - : base(HoverSampleSet.Loud) + : base(HoverSampleSet.Toolbar) { Width = Toolbar.HEIGHT; RelativeSizeAxes = Axes.Y; @@ -127,9 +129,9 @@ namespace osu.Game.Overlays.Toolbar { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.Both, // stops us being considered in parent's autosize - Anchor = TooltipAnchor.HasFlag(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, + Anchor = TooltipAnchor.HasFlagFast(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, Origin = TooltipAnchor, - Position = new Vector2(TooltipAnchor.HasFlag(Anchor.x0) ? 5 : -5, 5), + Position = new Vector2(TooltipAnchor.HasFlagFast(Anchor.x0) ? 5 : -5, 5), Alpha = 0, Children = new Drawable[] { diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index 81027667fa..299a14b250 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -15,6 +15,7 @@ using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Sections; using osu.Game.Users; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays { @@ -29,10 +30,14 @@ namespace osu.Game.Overlays public const float CONTENT_X_MARGIN = 70; public UserProfileOverlay() - : base(OverlayColourScheme.Pink, new ProfileHeader()) + : base(OverlayColourScheme.Pink) { } + protected override ProfileHeader CreateHeader() => new ProfileHeader(); + + protected override Color4 BackgroundColour => ColourProvider.Background6; + public void ShowUser(int userId) => ShowUser(new User { Id = userId }); public void ShowUser(User user, bool fetchOnline = true) @@ -72,12 +77,6 @@ namespace osu.Game.Overlays Origin = Anchor.TopCentre, }; - Add(new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6 - }); - Add(sectionsContainer = new ProfileSectionsContainer { ExpandableHeader = Header, @@ -202,7 +201,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both; } - protected override OsuScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); + protected override UserTrackingScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); protected override FlowContainer CreateScrollContentContainer() => new FillFlowContainer { diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 07accf8820..5b997bbd05 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -176,6 +176,7 @@ namespace osu.Game.Overlays.Volume } } }; + Bindable.ValueChanged += volume => { this.TransformTo("DisplayVolume", @@ -183,6 +184,7 @@ namespace osu.Game.Overlays.Volume 400, Easing.OutQuint); }; + bgProgress.Current.Value = 0.75f; } diff --git a/osu.Game/Overlays/WaveOverlayContainer.cs b/osu.Game/Overlays/WaveOverlayContainer.cs index d0fa9987d5..52ae4dbdbb 100644 --- a/osu.Game/Overlays/WaveOverlayContainer.cs +++ b/osu.Game/Overlays/WaveOverlayContainer.cs @@ -18,6 +18,8 @@ namespace osu.Game.Overlays protected override bool StartHidden => true; + protected override string PopInSampleName => "UI/wave-pop-in"; + protected WaveOverlayContainer() { AddInternal(Waves = new WaveContainer diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index 7999023998..6f979b8dc8 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -5,14 +5,13 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Threading; -using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Notifications; +using osu.Game.Screens; using osu.Game.Screens.Menu; namespace osu.Game @@ -29,9 +28,6 @@ namespace osu.Game [Resolved] private DialogOverlay dialogOverlay { get; set; } - [Resolved] - private IBindable beatmap { get; set; } - [Resolved(canBeNull: true)] private OsuGame game { get; set; } @@ -81,24 +77,45 @@ namespace osu.Game game?.CloseAllOverlays(false); - // we may already be at the target screen type. - if (validScreens.Contains(current.GetType()) && !beatmap.Disabled) + findValidTarget(current); + } + + private bool findValidTarget(IScreen current) + { + var type = current.GetType(); + + // check if we are already at a valid target screen. + if (validScreens.Any(t => t.IsAssignableFrom(type))) { finalAction(current); Cancel(); - return; + return true; } while (current != null) { - if (validScreens.Contains(current.GetType())) + // if this has a sub stack, recursively check the screens within it. + if (current is IHasSubScreenStack currentSubScreen) + { + if (findValidTarget(currentSubScreen.SubScreenStack.CurrentScreen)) + { + // should be correct in theory, but currently untested/unused in existing implementations. + current.MakeCurrent(); + return true; + } + } + + if (validScreens.Any(t => t.IsAssignableFrom(type))) { current.MakeCurrent(); - break; + return true; } current = current.GetParentScreen(); + type = current?.GetType(); } + + return false; } /// diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index 74bacae9e1..f6abf259e8 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -1,38 +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 MessagePack; using Newtonsoft.Json; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Replays; using osuTK; namespace osu.Game.Replays.Legacy { + [MessagePackObject] public class LegacyReplayFrame : ReplayFrame { [JsonIgnore] + [IgnoreMember] public Vector2 Position => new Vector2(MouseX ?? 0, MouseY ?? 0); + [Key(1)] public float? MouseX; + + [Key(2)] public float? MouseY; [JsonIgnore] + [IgnoreMember] public bool MouseLeft => MouseLeft1 || MouseLeft2; [JsonIgnore] + [IgnoreMember] public bool MouseRight => MouseRight1 || MouseRight2; [JsonIgnore] - public bool MouseLeft1 => ButtonState.HasFlag(ReplayButtonState.Left1); + [IgnoreMember] + public bool MouseLeft1 => ButtonState.HasFlagFast(ReplayButtonState.Left1); [JsonIgnore] - public bool MouseRight1 => ButtonState.HasFlag(ReplayButtonState.Right1); + [IgnoreMember] + public bool MouseRight1 => ButtonState.HasFlagFast(ReplayButtonState.Right1); [JsonIgnore] - public bool MouseLeft2 => ButtonState.HasFlag(ReplayButtonState.Left2); + [IgnoreMember] + public bool MouseLeft2 => ButtonState.HasFlagFast(ReplayButtonState.Left2); [JsonIgnore] - public bool MouseRight2 => ButtonState.HasFlag(ReplayButtonState.Right2); + [IgnoreMember] + public bool MouseRight2 => ButtonState.HasFlagFast(ReplayButtonState.Right2); + [Key(3)] public ReplayButtonState ButtonState; public LegacyReplayFrame(double time, float? mouseX, float? mouseY, ReplayButtonState buttonState) diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index f15e5e1df0..a25dc3e6db 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Difficulty private DifficultyAttributes calculate(IBeatmap beatmap, Mod[] mods, double clockRate) { - var skills = CreateSkills(beatmap); + var skills = CreateSkills(beatmap, mods); if (!beatmap.HitObjects.Any()) return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); @@ -202,7 +202,8 @@ namespace osu.Game.Rulesets.Difficulty /// Creates the s to calculate the difficulty of an . /// /// The whose difficulty will be calculated. + /// Mods to calculate difficulty with. /// The s. - protected abstract Skill[] CreateSkills(IBeatmap beatmap); + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 1063a24b27..95117be073 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills { @@ -46,10 +47,22 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// protected double CurrentStrain { get; private set; } = 1; + /// + /// Mods for use in skill calculations. + /// + protected IReadOnlyList Mods => mods; + private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. private readonly List strainPeaks = new List(); + private readonly Mod[] mods; + + protected Skill(Mod[] mods) + { + this.mods = mods; + } + /// /// Process a and update current strain values accordingly. /// diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 35852f60ea..e927951d0a 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -332,7 +332,7 @@ namespace osu.Game.Rulesets.Edit EditorBeatmap.Add(hitObject); if (EditorClock.CurrentTime < hitObject.StartTime) - EditorClock.SeekTo(hitObject.StartTime); + EditorClock.SeekSmoothlyTo(hitObject.StartTime); } } diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index c0eb891f5e..bfff93e7c5 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; @@ -45,6 +46,9 @@ namespace osu.Game.Rulesets.Edit { HitObject = hitObject; + // adding the default hit sample should be the case regardless of the ruleset. + HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); + RelativeSizeAxes = Axes.Both; // This is required to allow the blueprint's position to be updated via OnMouseMove/Handle diff --git a/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs b/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs new file mode 100644 index 0000000000..13cc41f8e0 --- /dev/null +++ b/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.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.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; + +namespace osu.Game.Rulesets.Filter +{ + /// + /// Allows for extending the beatmap filtering capabilities of song select (as implemented in ) + /// with ruleset-specific criteria. + /// + public interface IRulesetFilterCriteria + { + /// + /// Checks whether the supplied satisfies ruleset-specific custom criteria, + /// in addition to the ones mandated by song select. + /// + /// The beatmap to test the criteria against. + /// + /// true if the beatmap matches the ruleset-specific custom filtering criteria, + /// false otherwise. + /// + bool Matches(BeatmapInfo beatmap); + + /// + /// Attempts to parse a single custom keyword criterion, given by the user via the song select search box. + /// The format of the criterion is: + /// + /// {key}{op}{value} + /// + /// + /// + /// + /// For adding optional string criteria, can be used for matching, + /// along with for parsing. + /// + /// + /// For adding numerical-type range criteria, can be used for matching, + /// along with + /// and - and -typed overloads for parsing. + /// + /// + /// The key (name) of the criterion. + /// The operator in the criterion. + /// The value of the criterion. + /// + /// true if the keyword criterion is valid, false if it has been ignored. + /// Valid criteria are stripped from , + /// while ignored criteria are included in . + /// + bool TryParseCustomKeywordCriteria(string key, Operator op, string value); + } +} diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index da9bb8a09d..feeafb7151 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -150,17 +150,13 @@ namespace osu.Game.Rulesets.Judgements } if (JudgementBody.Drawable is IAnimatableJudgement animatable) - { - var drawableAnimation = (Drawable)animatable; - animatable.PlayAnimation(); - // a derived version of DrawableJudgement may be proposing a lifetime. - // if not adjusted (or the skinned portion requires greater bounds than calculated) use the skinned source's lifetime. - double lastTransformTime = drawableAnimation.LatestTransformEndTime; - if (LifetimeEnd == double.MaxValue || lastTransformTime > LifetimeEnd) - LifetimeEnd = lastTransformTime; - } + // a derived version of DrawableJudgement may be proposing a lifetime. + // if not adjusted (or the skinned portion requires greater bounds than calculated) use the skinned source's lifetime. + double lastTransformTime = JudgementBody.Drawable.LatestTransformEndTime; + if (LifetimeEnd == double.MaxValue || lastTransformTime > LifetimeEnd) + LifetimeEnd = lastTransformTime; } } diff --git a/osu.Game/Rulesets/Mods/IApplicableToRate.cs b/osu.Game/Rulesets/Mods/IApplicableToRate.cs new file mode 100644 index 0000000000..f613867132 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToRate.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. + +namespace osu.Game.Rulesets.Mods +{ + /// + /// Interface that should be implemented by mods that affect the track playback speed, + /// and in turn, values of the track rate. + /// + public interface IApplicableToRate : IApplicableToAudio + { + /// + /// Returns the playback rate at after this mod is applied. + /// + /// The time instant at which the playback rate is queried. + /// The playback rate before applying this mod. + /// The playback rate after applying this mod. + double ApplyToRate(double time, double rate = 1); + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 3a8717e678..2a11c92223 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -134,7 +134,7 @@ namespace osu.Game.Rulesets.Mods } /// - /// Copies mod setting values from into this instance. + /// Copies mod setting values from into this instance, overwriting all existing settings. /// /// The mod to copy properties from. public void CopyFrom(Mod source) @@ -147,9 +147,7 @@ namespace osu.Game.Rulesets.Mods var targetBindable = (IBindable)prop.GetValue(this); var sourceBindable = (IBindable)prop.GetValue(source); - // we only care about changes that have been made away from defaults. - if (!sourceBindable.IsDefault) - CopyAdjustedSetting(targetBindable, sourceBindable); + CopyAdjustedSetting(targetBindable, sourceBindable); } } @@ -175,5 +173,10 @@ namespace osu.Game.Rulesets.Mods } public bool Equals(IMod other) => GetType() == other?.GetType(); + + /// + /// Reset all custom settings for this mod back to their defaults. + /// + public virtual void ResetSettingsToDefaults() => CopyFrom((Mod)Activator.CreateInstance(GetType())); } } diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 945dd444be..d6e1d46b06 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -15,7 +16,10 @@ namespace osu.Game.Rulesets.Mods public abstract class ModAutoplay : ModAutoplay, IApplicableToDrawableRuleset where T : HitObject { - public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) => drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap)); + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); + } } public abstract class ModAutoplay : Mod, IApplicableFailOverride @@ -31,10 +35,15 @@ namespace osu.Game.Rulesets.Mods public bool RestartOnFail => false; - public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; + [Obsolete("Use the mod-supporting override")] // can be removed 20210731 public virtual Score CreateReplayScore(IBeatmap beatmap) => new Score { Replay = new Replay() }; + +#pragma warning disable 618 + public virtual Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => CreateReplayScore(beatmap); +#pragma warning restore 618 } } diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index bee9e56edd..eb0473016a 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap)); + drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); // AlwaysPresent required for hitsounds drawableRuleset.Playfield.AlwaysPresent = true; diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index a531e885db..b70eee4e1d 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mods protected const int LAST_SETTING_ORDER = 2; [SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER)] - public BindableNumber DrainRate { get; } = new BindableFloat + public BindableNumber DrainRate { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Mods }; [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER)] - public BindableNumber OverallDifficulty { get; } = new BindableFloat + public BindableNumber OverallDifficulty { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -53,6 +53,24 @@ namespace osu.Game.Rulesets.Mods Value = 5, }; + [SettingSource("Extended Limits", "Adjust difficulty beyond sane limits.")] + public BindableBool ExtendedLimits { get; } = new BindableBool(); + + protected ModDifficultyAdjust() + { + ExtendedLimits.BindValueChanged(extend => ApplyLimits(extend.NewValue)); + } + + /// + /// Changes the difficulty adjustment limits. Occurs when the value of is changed. + /// + /// Whether limits should extend beyond sane ranges. + protected virtual void ApplyLimits(bool extended) + { + DrainRate.MaxValue = extended ? 11 : 10; + OverallDifficulty.MaxValue = extended ? 11 : 10; + } + public override string SettingDescription { get @@ -141,5 +159,73 @@ namespace osu.Game.Rulesets.Mods ApplySetting(DrainRate, dr => difficulty.DrainRate = dr); ApplySetting(OverallDifficulty, od => difficulty.OverallDifficulty = od); } + + public override void ResetSettingsToDefaults() + { + base.ResetSettingsToDefaults(); + + if (difficulty != null) + { + // base implementation potentially overwrite modified defaults that came from a beatmap selection. + TransferSettings(difficulty); + } + } + + /// + /// A that extends its min/max values to support any assigned value. + /// + protected class BindableDoubleWithLimitExtension : BindableDouble + { + public override double Value + { + get => base.Value; + set + { + if (value < MinValue) + MinValue = value; + if (value > MaxValue) + MaxValue = value; + base.Value = value; + } + } + } + + /// + /// A that extends its min/max values to support any assigned value. + /// + protected class BindableFloatWithLimitExtension : BindableFloat + { + public override float Value + { + get => base.Value; + set + { + if (value < MinValue) + MinValue = value; + if (value > MaxValue) + MaxValue = value; + base.Value = value; + } + } + } + + /// + /// A that extends its min/max values to support any assigned value. + /// + protected class BindableIntWithLimitExtension : BindableInt + { + public override int Value + { + get => base.Value; + set + { + if (value < MinValue) + MinValue = value; + if (value > MaxValue) + MaxValue = value; + base.Value = value; + } + } + } } } diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs new file mode 100644 index 0000000000..c0d7bae2b2 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModFailCondition : Mod, IApplicableToHealthProcessor, IApplicableFailOverride + { + public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; + + public virtual bool PerformFail() => true; + + public virtual bool RestartOnFail => true; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + healthProcessor.FailConditions += FailCondition; + } + + protected abstract bool FailCondition(HealthProcessor healthProcessor, JudgementResult result); + } +} diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 0e589735c1..4edcb0b074 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods { } - public void ApplyToDifficulty(BeatmapDifficulty difficulty) + public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty) { const float ratio = 1.4f; difficulty.CircleSize = Math.Min(difficulty.CircleSize * 1.3f, 10.0f); // CS uses a custom 1.3 ratio. diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index b95ec7490e..c0f24e116a 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -16,6 +16,6 @@ namespace osu.Game.Rulesets.Mods public override string Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModAutoplay) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) }; } } diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index df0fc9c4b6..d0b09b50f2 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; @@ -8,13 +10,18 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModPerfect : ModSuddenDeath + public abstract class ModPerfect : ModFailCondition { public override string Name => "Perfect"; public override string Acronym => "PF"; public override IconUsage? Icon => OsuIcon.ModPerfect; + public override ModType Type => ModType.DifficultyIncrease; + public override bool Ranked => true; + public override double ScoreMultiplier => 1; public override string Description => "SS or quit."; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModSuddenDeath)).ToArray(); + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => result.Type.AffectsAccuracy() && result.Type != result.Judgement.MaxResult; diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 2150b0fb68..e66650f7b4 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.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.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; @@ -8,7 +9,7 @@ using osu.Framework.Graphics.Audio; namespace osu.Game.Rulesets.Mods { - public abstract class ModRateAdjust : Mod, IApplicableToAudio + public abstract class ModRateAdjust : Mod, IApplicableToRate { public abstract BindableNumber SpeedChange { get; } @@ -22,6 +23,10 @@ namespace osu.Game.Rulesets.Mods sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); } + public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; + + public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) }; + public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; } } diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs index b6fec42f43..e5995ff180 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.cs @@ -14,6 +14,6 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModRelax; public override ModType Type => ModType.Automation; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModSuddenDeath) }; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }; } } diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index ae71041a64..617ae38feb 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; @@ -9,7 +10,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModSuddenDeath : Mod, IApplicableToHealthProcessor, IApplicableFailOverride + public abstract class ModSuddenDeath : ModFailCondition { public override string Name => "Sudden Death"; public override string Acronym => "SD"; @@ -18,18 +19,10 @@ namespace osu.Game.Rulesets.Mods public override string Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; - public bool PerformFail() => true; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); - public bool RestartOnFail => true; - - public void ApplyToHealthProcessor(HealthProcessor healthProcessor) - { - healthProcessor.FailConditions += FailCondition; - } - - protected virtual bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => result.Type.AffectsCombo() && !result.IsHit; } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 4d43ae73d3..b5cd64dafa 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -14,12 +14,12 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods { - public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToAudio + public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToRate { /// /// The point in the beatmap at which the final ramping rate should be reached. /// - private const double final_rate_progress = 0.75f; + public const double FINAL_RATE_PROGRESS = 0.75f; [SettingSource("Initial rate", "The starting speed of the track")] public abstract BindableNumber InitialRate { get; } @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public abstract BindableBool AdjustPitch { get; } + public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust) }; + public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x"; private double finalRateTime; @@ -47,7 +49,7 @@ namespace osu.Game.Rulesets.Mods protected ModTimeRamp() { // for preview purpose at song select. eventually we'll want to be able to update every frame. - FinalRate.BindValueChanged(val => applyRateAdjustment(1), true); + FinalRate.BindValueChanged(val => applyRateAdjustment(double.PositiveInfinity), true); AdjustPitch.BindValueChanged(applyPitchAdjustment); } @@ -66,25 +68,33 @@ namespace osu.Game.Rulesets.Mods public virtual void ApplyToBeatmap(IBeatmap beatmap) { - HitObject lastObject = beatmap.HitObjects.LastOrDefault(); - SpeedChange.SetDefault(); - beginRampTime = beatmap.HitObjects.FirstOrDefault()?.StartTime ?? 0; - finalRateTime = final_rate_progress * (lastObject?.GetEndTime() ?? 0); + double firstObjectStart = beatmap.HitObjects.FirstOrDefault()?.StartTime ?? 0; + double lastObjectEnd = beatmap.HitObjects.LastOrDefault()?.GetEndTime() ?? 0; + + beginRampTime = firstObjectStart; + finalRateTime = firstObjectStart + FINAL_RATE_PROGRESS * (lastObjectEnd - firstObjectStart); + } + + public double ApplyToRate(double time, double rate = 1) + { + double amount = (time - beginRampTime) / Math.Max(1, finalRateTime - beginRampTime); + double ramp = InitialRate.Value + (FinalRate.Value - InitialRate.Value) * Math.Clamp(amount, 0, 1); + + // round the end result to match the bindable SpeedChange's precision, in case this is called externally. + return rate * Math.Round(ramp, 2); } public virtual void Update(Playfield playfield) { - applyRateAdjustment((track.CurrentTime - beginRampTime) / finalRateTime); + applyRateAdjustment(track.CurrentTime); } /// - /// Adjust the rate along the specified ramp + /// Adjust the rate along the specified ramp. /// - /// The amount of adjustment to apply (from 0..1). - private void applyRateAdjustment(double amount) => - SpeedChange.Value = InitialRate.Value + (FinalRate.Value - InitialRate.Value) * Math.Clamp(amount, 0, 1); + private void applyRateAdjustment(double time) => SpeedChange.Value = ApplyToRate(time); private void applyPitchAdjustment(ValueChangedEvent adjustPitchSetting) { diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 679b50057b..08bd44f7bd 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Initial rate", "The starting speed of the track")] public override BindableNumber InitialRate { get; } = new BindableDouble { - MinValue = 1, + MinValue = 0.51, MaxValue = 2, Default = 1, Value = 1, @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Mods public override BindableNumber FinalRate { get; } = new BindableDouble { MinValue = 0.5, - MaxValue = 0.99, + MaxValue = 1.99, Default = 0.75, Value = 0.75, Precision = 0.01, @@ -45,5 +45,20 @@ namespace osu.Game.Rulesets.Mods }; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindUp)).ToArray(); + + public ModWindDown() + { + InitialRate.BindValueChanged(val => + { + if (val.NewValue <= FinalRate.Value) + FinalRate.Value = val.NewValue - FinalRate.Precision; + }); + + FinalRate.BindValueChanged(val => + { + if (val.NewValue >= InitialRate.Value) + InitialRate.Value = val.NewValue + InitialRate.Precision; + }); + } } } diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index b733bf423e..df8f781148 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods public override BindableNumber InitialRate { get; } = new BindableDouble { MinValue = 0.5, - MaxValue = 1, + MaxValue = 1.99, Default = 1, Value = 1, Precision = 0.01, @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Final rate", "The speed increase to ramp towards")] public override BindableNumber FinalRate { get; } = new BindableDouble { - MinValue = 1.01, + MinValue = 0.51, MaxValue = 2, Default = 1.5, Value = 1.5, @@ -45,5 +45,20 @@ namespace osu.Game.Rulesets.Mods }; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown)).ToArray(); + + public ModWindUp() + { + InitialRate.BindValueChanged(val => + { + if (val.NewValue >= FinalRate.Value) + FinalRate.Value = val.NewValue + FinalRate.Precision; + }); + + FinalRate.BindValueChanged(val => + { + if (val.NewValue <= InitialRate.Value) + InitialRate.Value = val.NewValue - InitialRate.Precision; + }); + } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 72025de131..8419dd66de 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -10,6 +10,7 @@ using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; using osu.Game.Beatmaps.Legacy; using osu.Game.Skinning; @@ -54,7 +55,7 @@ namespace osu.Game.Rulesets.Objects.Legacy int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; type &= ~LegacyHitObjectType.ComboOffset; - bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); + bool combo = type.HasFlagFast(LegacyHitObjectType.NewCombo); type &= ~LegacyHitObjectType.NewCombo; var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]); @@ -62,14 +63,14 @@ namespace osu.Game.Rulesets.Objects.Legacy HitObject result = null; - if (type.HasFlag(LegacyHitObjectType.Circle)) + if (type.HasFlagFast(LegacyHitObjectType.Circle)) { result = CreateHit(pos, combo, comboOffset); if (split.Length > 5) readCustomSampleBanks(split[5], bankInfo); } - else if (type.HasFlag(LegacyHitObjectType.Slider)) + else if (type.HasFlagFast(LegacyHitObjectType.Slider)) { double? length = null; @@ -141,7 +142,7 @@ namespace osu.Game.Rulesets.Objects.Legacy result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); } - else if (type.HasFlag(LegacyHitObjectType.Spinner)) + else if (type.HasFlagFast(LegacyHitObjectType.Spinner)) { double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime); @@ -150,7 +151,7 @@ namespace osu.Game.Rulesets.Objects.Legacy if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); } - else if (type.HasFlag(LegacyHitObjectType.Hold)) + else if (type.HasFlagFast(LegacyHitObjectType.Hold)) { // Note: Hold is generated by BMS converts @@ -436,16 +437,16 @@ namespace osu.Game.Rulesets.Objects.Legacy new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.Normal, bankInfo.Volume, bankInfo.CustomSampleBank, // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds - type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal)) + type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal)) }; - if (type.HasFlag(LegacyHitSoundType.Finish)) + if (type.HasFlagFast(LegacyHitSoundType.Finish)) soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); - if (type.HasFlag(LegacyHitSoundType.Whistle)) + if (type.HasFlagFast(LegacyHitSoundType.Whistle)) soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); - if (type.HasFlag(LegacyHitSoundType.Clap)) + if (type.HasFlagFast(LegacyHitSoundType.Clap)) soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); return soundTypes; diff --git a/osu.Game/Rulesets/Replays/ReplayFrame.cs b/osu.Game/Rulesets/Replays/ReplayFrame.cs index 85e068ae79..7de53211a2 100644 --- a/osu.Game/Rulesets/Replays/ReplayFrame.cs +++ b/osu.Game/Rulesets/Replays/ReplayFrame.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using MessagePack; + namespace osu.Game.Rulesets.Replays { + [MessagePackObject] public class ReplayFrame { + [Key(0)] public double Time; public ReplayFrame() diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index b3b3d11ab3..38d30a2e31 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -24,9 +24,10 @@ using osu.Game.Skinning; using osu.Game.Users; using JetBrains.Annotations; using osu.Framework.Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Testing; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Ranking.Statistics; -using osu.Game.Utils; namespace osu.Game.Rulesets { @@ -272,7 +273,7 @@ namespace osu.Game.Rulesets var validResults = GetValidHitResults(); // enumerate over ordered list to guarantee return order is stable. - foreach (var result in OrderAttributeUtils.GetValuesInOrder()) + foreach (var result in EnumExtensions.GetValuesInOrder()) { switch (result) { @@ -298,7 +299,7 @@ namespace osu.Game.Rulesets /// /// is implicitly included. Special types like are ignored even when specified. /// - protected virtual IEnumerable GetValidHitResults() => OrderAttributeUtils.GetValuesInOrder(); + protected virtual IEnumerable GetValidHitResults() => EnumExtensions.GetValuesInOrder(); /// /// Get a display friendly name for the specified result type. @@ -306,5 +307,11 @@ namespace osu.Game.Rulesets /// The result type to get the name for. /// The display name. public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription(); + + /// + /// Creates ruleset-specific beatmap filter criteria to be used on the song select screen. + /// + [CanBeNull] + public virtual IRulesetFilterCriteria CreateRulesetFilterCriteria() => null; } } diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 6a3a034fc1..eaa1f95744 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -3,7 +3,7 @@ using System.ComponentModel; using System.Diagnostics; -using osu.Game.Utils; +using osu.Framework.Utils; namespace osu.Game.Rulesets.Scoring { diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 6940e43e5b..ca27e6b21a 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.UI protected IRulesetConfigManager Config { get; private set; } [Cached(typeof(IReadOnlyList))] - protected override IReadOnlyList Mods { get; } + public sealed override IReadOnlyList Mods { get; } private FrameStabilityContainer frameStabilityContainer; @@ -434,7 +434,7 @@ namespace osu.Game.Rulesets.UI /// /// The mods which are to be applied. /// - protected abstract IReadOnlyList Mods { get; } + public abstract IReadOnlyList Mods { get; } /// ~ /// The associated ruleset. diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 81ec73a6c5..14aa3fe99a 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -63,6 +63,7 @@ namespace osu.Game.Rulesets.UI ~DrawableRulesetDependencies() { + // required to potentially clean up sample store from audio hierarchy. Dispose(false); } @@ -102,17 +103,17 @@ namespace osu.Game.Rulesets.UI this.fallback = fallback; } - public SampleChannel Get(string name) => primary.Get(name) ?? fallback.Get(name); + 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) => primary.GetAsync(name) ?? fallback.GetAsync(name); public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); public IEnumerable GetAvailableResources() => throw new NotSupportedException(); - public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => throw new NotSupportedException(); - public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => throw new NotSupportedException(); public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotSupportedException(); @@ -134,6 +135,10 @@ namespace osu.Game.Rulesets.UI public IBindable AggregateTempo => throw new NotSupportedException(); + public void BindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + + public void UnbindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + public int PlaybackConcurrency { get => throw new NotSupportedException(); diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 1972043ccb..11312a46df 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -17,19 +17,10 @@ using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.UI { - public class HitObjectContainer : LifetimeManagementContainer + public class HitObjectContainer : LifetimeManagementContainer, IHitObjectContainer { - /// - /// All currently in-use s. - /// public IEnumerable Objects => InternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); - /// - /// All currently in-use s that are alive. - /// - /// - /// If this uses pooled objects, this is equivalent to . - /// public IEnumerable AliveObjects => AliveInternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); /// diff --git a/osu.Game/Rulesets/UI/IHitObjectContainer.cs b/osu.Game/Rulesets/UI/IHitObjectContainer.cs new file mode 100644 index 0000000000..4c784132e8 --- /dev/null +++ b/osu.Game/Rulesets/UI/IHitObjectContainer.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.UI +{ + public interface IHitObjectContainer + { + /// + /// All currently in-use s. + /// + IEnumerable Objects { get; } + + /// + /// All currently in-use s that are alive. + /// + /// + /// If this uses pooled objects, this is equivalent to . + /// + IEnumerable AliveObjects { get; } + } +} diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 8ea6c74349..cae5da3d16 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -16,6 +16,9 @@ using osu.Framework.Bindables; namespace osu.Game.Rulesets.UI { + /// + /// Display the specified mod at a fixed size. + /// public class ModIcon : Container, IHasTooltip { public readonly BindableBool Selected = new BindableBool(); @@ -26,11 +29,10 @@ namespace osu.Game.Rulesets.UI private const float size = 80; - private readonly ModType type; - - public virtual string TooltipText => mod.IconTooltip; + public virtual string TooltipText => showTooltip ? mod.IconTooltip : null; private Mod mod; + private readonly bool showTooltip; public Mod Mod { @@ -38,15 +40,27 @@ namespace osu.Game.Rulesets.UI set { mod = value; - updateMod(value); + + if (IsLoaded) + updateMod(value); } } - public ModIcon(Mod mod) + [Resolved] + private OsuColour colours { get; set; } + + private Color4 backgroundColour; + private Color4 highlightedColour; + + /// + /// Construct a new instance. + /// + /// The mod to be displayed + /// Whether a tooltip describing the mod should display on hover. + public ModIcon(Mod mod, bool showTooltip = true) { this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); - - type = mod.Type; + this.showTooltip = showTooltip; Size = new Vector2(size); @@ -79,6 +93,13 @@ namespace osu.Game.Rulesets.UI Icon = FontAwesome.Solid.Question }, }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Selected.BindValueChanged(_ => updateColour()); updateMod(mod); } @@ -92,20 +113,14 @@ namespace osu.Game.Rulesets.UI { modIcon.FadeOut(); modAcronym.FadeIn(); - return; + } + else + { + modIcon.FadeIn(); + modAcronym.FadeOut(); } - modIcon.FadeIn(); - modAcronym.FadeOut(); - } - - private Color4 backgroundColour; - private Color4 highlightedColour; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - switch (type) + switch (value.Type) { default: case ModType.DifficultyIncrease: @@ -139,12 +154,13 @@ namespace osu.Game.Rulesets.UI modIcon.Colour = colours.Yellow; break; } + + updateColour(); } - protected override void LoadComplete() + private void updateColour() { - base.LoadComplete(); - Selected.BindValueChanged(selected => background.Colour = selected.NewValue ? highlightedColour : backgroundColour, true); + background.Colour = Selected.Value ? highlightedColour : backgroundColour; } } } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 07de2bf601..d6f002ea2c 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -16,7 +16,6 @@ using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; -using osuTK.Input; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.UI @@ -109,9 +108,9 @@ namespace osu.Game.Rulesets.UI { switch (e) { - case MouseDownEvent mouseDown when mouseDown.Button == MouseButton.Left || mouseDown.Button == MouseButton.Right: + case MouseDownEvent _: if (mouseDisabled.Value) - return false; + return true; // importantly, block upwards propagation so global bindings also don't fire. break; diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 0955f32790..6ffdad211b 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -91,7 +91,11 @@ namespace osu.Game.Rulesets.UI.Scrolling scrollingInfo = new LocalScrollingInfo(); scrollingInfo.Direction.BindTo(Direction); scrollingInfo.TimeRange.BindTo(TimeRange); + } + [BackgroundDependencyLoader] + private void load() + { switch (VisualisationMethod) { case ScrollVisualisationMethod.Sequential: @@ -106,11 +110,7 @@ namespace osu.Game.Rulesets.UI.Scrolling scrollingInfo.Algorithm = new ConstantScrollAlgorithm(); break; } - } - [BackgroundDependencyLoader] - private void load() - { double lastObjectTime = Objects.LastOrDefault()?.GetEndTime() ?? double.MaxValue; double baseBeatLength = TimingControlPoint.DEFAULT_BEAT_LENGTH; diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index f5192f3a40..78101991f6 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -73,7 +73,7 @@ namespace osu.Game.Scoring } set { - modsJson = null; + modsJson = JsonConvert.SerializeObject(value.Select(m => new DeserializedMod { Acronym = m.Acronym })); mods = value; } } @@ -86,16 +86,7 @@ namespace osu.Game.Scoring [Column("Mods")] public string ModsJson { - get - { - if (modsJson != null) - return modsJson; - - if (mods == null) - return null; - - return modsJson = JsonConvert.SerializeObject(mods.Select(m => new DeserializedMod { Acronym = m.Acronym })); - } + get => modsJson; set { modsJson = value; diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index cf1d123c06..7d0abc5996 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -20,6 +20,7 @@ using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring.Legacy; @@ -52,6 +53,11 @@ namespace osu.Game.Scoring this.configManager = configManager; } + protected override void PreImport(ScoreInfo model) + { + model.Requery(ContextFactory); + } + protected override ScoreInfo CreateModel(ArchiveReader archive) { if (archive == null) @@ -71,8 +77,9 @@ namespace osu.Game.Scoring } } - protected override IEnumerable GetStableImportPaths(Storage stableStorage) - => stableStorage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)); + protected override IEnumerable GetStableImportPaths(Storage storage) + => storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(path => storage.GetFullPath(path)); public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); @@ -136,7 +143,7 @@ namespace osu.Game.Scoring ScoringMode.BindValueChanged(onScoringModeChanged, true); } - private IBindable difficultyBindable; + private IBindable difficultyBindable; private CancellationTokenSource difficultyCancellationSource; private void onScoringModeChanged(ValueChangedEvent mode) @@ -151,9 +158,21 @@ namespace osu.Game.Scoring } int beatmapMaxCombo; + double accuracy = score.Accuracy; if (score.IsLegacyScore) { + if (score.RulesetID == 3) + { + // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. + // To get around this, recalculate accuracy based on the hit statistics. + // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. + double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); + double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); + if (maxBaseScore > 0) + accuracy = baseScore / maxBaseScore; + } + // This score is guaranteed to be an osu!stable score. // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. if (score.Beatmap.MaxCombo == null) @@ -167,7 +186,11 @@ namespace osu.Game.Scoring // We can compute the max combo locally after the async beatmap difficulty computation. difficultyBindable = difficulties().GetBindableDifficulty(score.Beatmap, score.Ruleset, score.Mods, (difficultyCancellationSource = new CancellationTokenSource()).Token); - difficultyBindable.BindValueChanged(d => updateScore(d.NewValue.MaxCombo), true); + difficultyBindable.BindValueChanged(d => + { + if (d.NewValue is StarDifficulty diff) + updateScore(diff.MaxCombo, accuracy); + }, true); return; } @@ -181,10 +204,10 @@ namespace osu.Game.Scoring beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetOrDefault(r)).Sum(); } - updateScore(beatmapMaxCombo); + updateScore(beatmapMaxCombo, accuracy); } - private void updateScore(int beatmapMaxCombo) + private void updateScore(int beatmapMaxCombo, double accuracy) { if (beatmapMaxCombo == 0) { @@ -197,7 +220,7 @@ namespace osu.Game.Scoring scoreProcessor.Mods.Value = score.Mods; - Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, score.Accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); } } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index 5f66c13d2f..bb15983de3 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -34,7 +34,7 @@ namespace osu.Game.Scoring { var score = lookup.ScoreInfo; - var attributes = await difficultyCache.GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, token); + var attributes = await difficultyCache.GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, token).ConfigureAwait(false); // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. if (attributes.Attributes == null) diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index c81362eebe..a6fb94b151 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -13,6 +13,8 @@ namespace osu.Game.Screens { private readonly bool animateOnEnter; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + protected BackgroundScreen(bool animateOnEnter = true) { this.animateOnEnter = animateOnEnter; @@ -68,15 +70,19 @@ namespace osu.Game.Screens public override bool OnExiting(IScreen next) { - this.FadeOut(transition_length, Easing.OutExpo); - this.MoveToX(x_movement_amount, transition_length, Easing.OutExpo); + if (IsLoaded) + { + this.FadeOut(transition_length, Easing.OutExpo); + this.MoveToX(x_movement_amount, transition_length, Easing.OutExpo); + } return base.OnExiting(next); } public override void OnResuming(IScreen last) { - this.MoveToX(0, transition_length, Easing.OutExpo); + if (IsLoaded) + this.MoveToX(0, transition_length, Easing.OutExpo); base.OnResuming(last); } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs index 103e39e78a..8298cf4773 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; @@ -13,7 +12,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public class BookmarkPart : TimelinePart { - protected override void LoadBeatmap(WorkingBeatmap beatmap) + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); foreach (int bookmark in beatmap.BeatmapInfo.Bookmarks) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs index ceccbffc9c..e8a4b5c8c7 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Beatmaps; using osu.Game.Beatmaps.Timing; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; @@ -14,10 +13,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public class BreakPart : TimelinePart { - protected override void LoadBeatmap(WorkingBeatmap beatmap) + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); - foreach (var breakPeriod in beatmap.Beatmap.Breaks) + foreach (var breakPeriod in beatmap.Breaks) Add(new BreakVisualisation(breakPeriod)); } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs index e76ab71e54..70afc1e308 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs @@ -4,7 +4,6 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Bindables; -using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts @@ -16,12 +15,12 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { private readonly IBindableList controlPointGroups = new BindableList(); - protected override void LoadBeatmap(WorkingBeatmap beatmap) + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); controlPointGroups.UnbindAll(); - controlPointGroups.BindTo(beatmap.Beatmap.ControlPointInfo.Groups); + controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups); controlPointGroups.BindCollectionChanged((sender, args) => { switch (args.Action) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index 9e9ac93d23..d551333616 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -2,15 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; 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.Threading; -using osu.Game.Beatmaps; using osu.Game.Graphics; +using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { @@ -54,11 +53,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts scheduledSeek?.Cancel(); scheduledSeek = Schedule(() => { - if (Beatmap.Value == null) - return; - float markerPos = Math.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth); - editorClock.SeekTo(markerPos / DrawWidth * editorClock.TrackLength); + editorClock.SeekSmoothlyTo(markerPos / DrawWidth * editorClock.TrackLength); }); } @@ -68,7 +64,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts marker.X = (float)editorClock.CurrentTime; } - protected override void LoadBeatmap(WorkingBeatmap beatmap) + protected override void LoadBeatmap(EditorBeatmap beatmap) { // block base call so we don't clear our marker (can be reused on beatmap change). } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs index 5b8f7c747b..5aba81aa7d 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs @@ -21,7 +21,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public class TimelinePart : Container where T : Drawable { - protected readonly IBindable Beatmap = new Bindable(); + private readonly IBindable beatmap = new Bindable(); + + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } protected readonly IBindable Track = new Bindable(); @@ -33,10 +36,9 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { AddInternal(this.content = content ?? new Container { RelativeSizeAxes = Axes.Both }); - Beatmap.ValueChanged += b => + beatmap.ValueChanged += b => { updateRelativeChildSize(); - LoadBeatmap(b.NewValue); }; Track.ValueChanged += _ => updateRelativeChildSize(); @@ -45,24 +47,26 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts [BackgroundDependencyLoader] private void load(IBindable beatmap, EditorClock clock) { - Beatmap.BindTo(beatmap); + this.beatmap.BindTo(beatmap); + LoadBeatmap(EditorBeatmap); + Track.BindTo(clock.Track); } private void updateRelativeChildSize() { // the track may not be loaded completely (only has a length once it is). - if (!Beatmap.Value.Track.IsLoaded) + if (!beatmap.Value.Track.IsLoaded) { content.RelativeChildSize = Vector2.One; Schedule(updateRelativeChildSize); return; } - content.RelativeChildSize = new Vector2((float)Math.Max(1, Beatmap.Value.Track.Length), 1); + content.RelativeChildSize = new Vector2((float)Math.Max(1, beatmap.Value.Track.Length), 1); } - protected virtual void LoadBeatmap(WorkingBeatmap beatmap) + protected virtual void LoadBeatmap(EditorBeatmap beatmap) { content.Clear(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 0b45bd5597..051d0766bf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -170,7 +170,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint) return false; - EditorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); + EditorClock?.SeekSmoothlyTo(clickedBlueprint.HitObject.StartTime); return true; } @@ -495,8 +495,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // Apply the start time at the newly snapped-to position double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime; - foreach (HitObject obj in Beatmap.SelectedHitObjects) - obj.StartTime += offset; + Beatmap.PerformOnSelection(obj => obj.StartTime += offset); } return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index c09b935f28..79f457c050 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -211,9 +211,6 @@ namespace osu.Game.Screens.Edit.Compose.Components if (blueprint != null) { - // doing this post-creations as adding the default hit sample should be the case regardless of the ruleset. - blueprint.HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); - placementBlueprintContainer.Child = currentPlacement = blueprint; // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 2f4721f63e..9d6b44e207 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -113,16 +113,25 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.Repeat || !e.ControlPressed) return false; + bool runOperationFromHotkey(Func operation) + { + operationStarted(); + bool result = operation?.Invoke() ?? false; + operationEnded(); + + return result; + } + switch (e.Key) { case Key.G: - return CanReverse && OnReverse?.Invoke() == true; + return CanReverse && runOperationFromHotkey(OnReverse); case Key.H: - return CanScaleX && OnFlip?.Invoke(Direction.Horizontal) == true; + return CanScaleX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); case Key.J: - return CanScaleY && OnFlip?.Invoke(Direction.Vertical) == true; + return CanScaleY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); } return base.OnKeyDown(e); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 788b485449..018d4d081c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -320,18 +320,14 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void AddHitSample(string sampleName) { - EditorBeatmap.BeginChange(); - - foreach (var h in EditorBeatmap.SelectedHitObjects) + EditorBeatmap.PerformOnSelection(h => { // Make sure there isn't already an existing sample if (h.Samples.Any(s => s.Name == sampleName)) - continue; + return; h.Samples.Add(new HitSampleInfo(sampleName)); - } - - EditorBeatmap.EndChange(); + }); } /// @@ -341,19 +337,15 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Throws if any selected object doesn't implement public void SetNewCombo(bool state) { - EditorBeatmap.BeginChange(); - - foreach (var h in EditorBeatmap.SelectedHitObjects) + EditorBeatmap.PerformOnSelection(h => { var comboInfo = h as IHasComboInformation; - if (comboInfo == null || comboInfo.NewCombo == state) continue; + if (comboInfo == null || comboInfo.NewCombo == state) return; comboInfo.NewCombo = state; EditorBeatmap.Update(h); - } - - EditorBeatmap.EndChange(); + }); } /// @@ -362,12 +354,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void RemoveHitSample(string sampleName) { - EditorBeatmap.BeginChange(); - - foreach (var h in EditorBeatmap.SelectedHitObjects) - h.SamplesBindable.RemoveAll(s => s.Name == sampleName); - - EditorBeatmap.EndChange(); + EditorBeatmap.PerformOnSelection(h => h.SamplesBindable.RemoveAll(s => s.Name == sampleName)); } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 12f7625bf9..0697dbb392 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -155,12 +155,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline seekTrackToCurrent(); else if (!editorClock.IsRunning) { - // The track isn't running. There are two cases we have to be wary of: - // 1) The user flick-drags on this timeline: We want the track to follow us - // 2) The user changes the track time through some other means (scrolling in the editor or overview timeline): We want to follow the track time + // The track isn't running. There are three cases we have to be wary of: + // 1) The user flick-drags on this timeline and we are applying an interpolated seek on the clock, until interrupted by 2 or 3. + // 2) The user changes the track time through some other means (scrolling in the editor or overview timeline; clicking a hitobject etc.). We want the timeline to track the clock's time. + // 3) An ongoing seek transform is running from an external seek. We want the timeline to track the clock's time. - // The simplest way to cover both cases is by checking whether the scroll position has changed and the audio hasn't been changed externally - if (Current != lastScrollPosition && editorClock.CurrentTime == lastTrackTime) + // The simplest way to cover the first two cases is by checking whether the scroll position has changed and the audio hasn't been changed externally + // Checking IsSeeking covers the third case, where the transform may not have been applied yet. + if (Current != lastScrollPosition && editorClock.CurrentTime == lastTrackTime && !editorClock.IsSeeking) seekTrackToCurrent(); else scrollToTrackTime(); @@ -175,7 +177,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (!track.IsLoaded) return; - editorClock.Seek(Current / Content.DrawWidth * track.Length); + double target = Current / Content.DrawWidth * track.Length; + editorClock.Seek(Math.Min(track.Length, target)); } private void scrollToTrackTime() diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs index 13191df13c..18600bcdee 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs @@ -5,7 +5,6 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -23,12 +22,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Both; } - protected override void LoadBeatmap(WorkingBeatmap beatmap) + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); controlPointGroups.UnbindAll(); - controlPointGroups.BindTo(beatmap.Beatmap.ControlPointInfo.Groups); + controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups); controlPointGroups.BindCollectionChanged((sender, args) => { switch (args.Action) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index ae2a82fa10..d24614299c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -387,7 +388,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline case IHasDuration endTimeHitObject: var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time)); - if (endTimeHitObject.EndTime == snappedTime) + if (endTimeHitObject.EndTime == snappedTime || Precision.AlmostEquals(snappedTime, hitObject.StartTime, beatmap.GetBeatLengthAtTime(snappedTime))) return; endTimeHitObject.Duration = snappedTime - hitObject.StartTime; diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index b7ebf0c0a4..0c24eb6a4d 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -74,7 +74,6 @@ namespace osu.Game.Screens.Edit private string lastSavedHash; - private Box bottomBackground; private Container screenContainer; private EditorScreen currentScreen; @@ -106,17 +105,29 @@ namespace osu.Game.Screens.Edit [BackgroundDependencyLoader] private void load(OsuColour colours, GameHost host, OsuConfigManager config) { - if (Beatmap.Value is DummyWorkingBeatmap) + var loadableBeatmap = Beatmap.Value; + + if (loadableBeatmap is DummyWorkingBeatmap) { isNewBeatmap = true; - Beatmap.Value = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); + + loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); + + // required so we can get the track length in EditorClock. + // this is safe as nothing has yet got a reference to this new beatmap. + loadableBeatmap.LoadTrack(); + + // this is a bit haphazard, but guards against setting the lease Beatmap bindable if + // the editor has already been exited. + if (!ValidForPush) + return; } - beatDivisor.Value = Beatmap.Value.BeatmapInfo.BeatDivisor; - beatDivisor.BindValueChanged(divisor => Beatmap.Value.BeatmapInfo.BeatDivisor = divisor.NewValue); + beatDivisor.Value = loadableBeatmap.BeatmapInfo.BeatDivisor; + beatDivisor.BindValueChanged(divisor => loadableBeatmap.BeatmapInfo.BeatDivisor = divisor.NewValue); // Todo: should probably be done at a DrawableRuleset level to share logic with Player. - clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false }; + clock = new EditorClock(loadableBeatmap, beatDivisor) { IsCoupled = false }; UpdateClockSource(); @@ -130,7 +141,11 @@ namespace osu.Game.Screens.Edit try { - playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + playableBeatmap = loadableBeatmap.GetPlayableBeatmap(loadableBeatmap.BeatmapInfo.Ruleset); + + // clone these locally for now to avoid incurring overhead on GetPlayableBeatmap usages. + // eventually we will want to improve how/where this is done as there are issues with *not* cloning it in all cases. + playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.CreateCopy(); } catch (Exception e) { @@ -140,13 +155,21 @@ namespace osu.Game.Screens.Edit return; } - AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, Beatmap.Value.Skin)); + AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.Skin)); dependencies.CacheAs(editorBeatmap); changeHandler = new EditorChangeHandler(editorBeatmap); dependencies.CacheAs(changeHandler); updateLastSavedHash(); + Schedule(() => + { + // we need to avoid changing the beatmap from an asynchronous load thread. it can potentially cause weirdness including crashes. + // this assumes that nothing during the rest of this load() method is accessing Beatmap.Value (loadableBeatmap should be preferred). + // generally this is quite safe, as the actual load of editor content comes after menuBar.Mode.ValueChanged is fired in its own LoadComplete. + Beatmap.Value = loadableBeatmap; + }); + OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; @@ -154,17 +177,6 @@ namespace osu.Game.Screens.Edit EditorMenuItem copyMenuItem; EditorMenuItem pasteMenuItem; - var fileMenuItems = new List - { - new EditorMenuItem("Save", MenuItemType.Standard, Save) - }; - - if (RuntimeInfo.IsDesktop) - fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); - - fileMenuItems.Add(new EditorMenuItemSpacer()); - fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); - AddInternal(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -196,7 +208,7 @@ namespace osu.Game.Screens.Edit { new MenuItem("File") { - Items = fileMenuItems + Items = createFileMenuItems() }, new MenuItem("Edit") { @@ -229,7 +241,11 @@ namespace osu.Game.Screens.Edit Height = 60, Children = new Drawable[] { - bottomBackground = new Box { RelativeSizeAxes = Axes.Both }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray2 + }, new Container { RelativeSizeAxes = Axes.Both, @@ -286,8 +302,6 @@ namespace osu.Game.Screens.Edit clipboard.BindValueChanged(content => pasteMenuItem.Action.Disabled = string.IsNullOrEmpty(content.NewValue)); menuBar.Mode.ValueChanged += onModeChanged; - - bottomBackground.Colour = colours.Gray2; } /// @@ -668,6 +682,21 @@ namespace osu.Game.Screens.Edit lastSavedHash = changeHandler.CurrentStateHash; } + private List createFileMenuItems() + { + var fileMenuItems = new List + { + new EditorMenuItem("Save", MenuItemType.Standard, Save) + }; + + if (RuntimeInfo.IsDesktop) + fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); + + fileMenuItems.Add(new EditorMenuItemSpacer()); + fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); + return fileMenuItems; + } + public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime); diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 165d2ba278..4f1b0484d2 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -74,7 +74,11 @@ namespace osu.Game.Screens.Edit public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; - public ControlPointInfo ControlPointInfo => PlayableBeatmap.ControlPointInfo; + public ControlPointInfo ControlPointInfo + { + get => PlayableBeatmap.ControlPointInfo; + set => PlayableBeatmap.ControlPointInfo = value; + } public List Breaks => PlayableBeatmap.Breaks; @@ -84,6 +88,8 @@ namespace osu.Game.Screens.Edit public IEnumerable GetStatistics() => PlayableBeatmap.GetStatistics(); + public double GetMostCommonBeatLength() => PlayableBeatmap.GetMostCommonBeatLength(); + public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone(); private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; @@ -94,6 +100,22 @@ namespace osu.Game.Screens.Edit private readonly HashSet batchPendingUpdates = new HashSet(); + /// + /// Perform the provided action on every selected hitobject. + /// Changes will be grouped as one history action. + /// + /// The action to perform. + public void PerformOnSelection(Action action) + { + if (SelectedHitObjects.Count == 0) + return; + + BeginChange(); + foreach (var h in SelectedHitObjects) + action(h); + EndChange(); + } + /// /// Adds a collection of s to this . /// diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 148eef6c93..d0197ce1ec 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -31,10 +31,17 @@ namespace osu.Game.Screens.Edit private readonly DecoupleableInterpolatingFramedClock underlyingClock; + private bool playbackFinished; + public IBindable SeekingOrStopped => seekingOrStopped; private readonly Bindable seekingOrStopped = new Bindable(true); + /// + /// Whether a seek is currently in progress. True for the duration of a seek performed via . + /// + public bool IsSeeking { get; private set; } + public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor) : this(beatmap.Beatmap.ControlPointInfo, beatmap.Track.Length, beatDivisor) { @@ -111,7 +118,7 @@ namespace osu.Game.Screens.Edit if (!snapped || ControlPointInfo.TimingPoints.Count == 0) { - SeekTo(seekTime); + SeekSmoothlyTo(seekTime); return; } @@ -145,11 +152,11 @@ namespace osu.Game.Screens.Edit // Ensure the sought point is within the boundaries seekTime = Math.Clamp(seekTime, 0, TrackLength); - SeekTo(seekTime); + SeekSmoothlyTo(seekTime); } /// - /// The current time of this clock, include any active transform seeks performed via . + /// The current time of this clock, include any active transform seeks performed via . /// public double CurrentTimeAccurate => Transforms.OfType().FirstOrDefault()?.EndValue ?? CurrentTime; @@ -165,6 +172,10 @@ namespace osu.Game.Screens.Edit public void Start() { ClearTransforms(); + + if (playbackFinished) + underlyingClock.Seek(0); + underlyingClock.Start(); } @@ -176,12 +187,29 @@ namespace osu.Game.Screens.Edit public bool Seek(double position) { - seekingOrStopped.Value = true; + seekingOrStopped.Value = IsSeeking = true; ClearTransforms(); return underlyingClock.Seek(position); } + /// + /// Seek smoothly to the provided destination. + /// Use to perform an immediate seek. + /// + /// + public void SeekSmoothlyTo(double seekDestination) + { + seekingOrStopped.Value = true; + + if (IsRunning) + Seek(seekDestination); + else + { + transformSeekTo(seekDestination, transform_time, Easing.OutQuint); + } + } + public void ResetSpeedAdjustments() => underlyingClock.ResetSpeedAdjustments(); double IAdjustableClock.Rate @@ -194,7 +222,21 @@ namespace osu.Game.Screens.Edit public bool IsRunning => underlyingClock.IsRunning; - public void ProcessFrame() => underlyingClock.ProcessFrame(); + public void ProcessFrame() + { + underlyingClock.ProcessFrame(); + + playbackFinished = CurrentTime >= TrackLength; + + if (playbackFinished) + { + if (IsRunning) + underlyingClock.Stop(); + + if (CurrentTime > TrackLength) + underlyingClock.Seek(TrackLength); + } + } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; @@ -229,6 +271,8 @@ namespace osu.Game.Screens.Edit { if (seekingOrStopped.Value) { + IsSeeking &= Transforms.Any(); + if (track.Value?.IsRunning != true) { // seeking in the editor can happen while the track isn't running. @@ -239,20 +283,10 @@ namespace osu.Game.Screens.Edit // we are either running a seek tween or doing an immediate seek. // in the case of an immediate seek the seeking bool will be set to false after one update. // this allows for silencing hit sounds and the likes. - seekingOrStopped.Value = Transforms.Any(); + seekingOrStopped.Value = IsSeeking; } } - public void SeekTo(double seekDestination) - { - seekingOrStopped.Value = true; - - if (IsRunning) - Seek(seekDestination); - else - transformSeekTo(seekDestination, transform_time, Easing.OutQuint); - } - private void transformSeekTo(double seek, double duration = 0, Easing easing = Easing.None) => this.TransformTo(this.PopulateTransform(new TransformSeek(), seek, duration, easing)); diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index e812c042fb..f429164ece 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -56,6 +56,14 @@ namespace osu.Game.Screens.Edit.Setup item.OnCommit += onCommit; } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (string.IsNullOrEmpty(artistTextBox.Current.Value)) + GetContainingInputManager().ChangeFocus(artistTextBox); + } + private void onCommit(TextBox sender, bool newText) { if (!newText) return; diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 89d3c36250..a17b431fcc 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -74,7 +74,8 @@ namespace osu.Game.Screens.Edit.Timing { new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), new TableColumn("Time", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Attributes", Anchor.Centre), + new TableColumn(), + new TableColumn("Attributes", Anchor.CentreLeft), }; return columns.ToArray(); @@ -93,6 +94,7 @@ namespace osu.Game.Screens.Edit.Timing Text = group.Time.ToEditorFormattedString(), Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, + null, new ControlGroupAttributes(group), }; @@ -104,11 +106,11 @@ namespace osu.Game.Screens.Edit.Timing public ControlGroupAttributes(ControlPointGroup group) { + RelativeSizeAxes = Axes.Both; InternalChild = fill = new FillFlowContainer { RelativeSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Padding = new MarginPadding(10), Spacing = new Vector2(2) }; @@ -149,7 +151,10 @@ namespace osu.Game.Screens.Edit.Timing return new RowAttribute("difficulty", () => $"{difficulty.SpeedMultiplier:n2}x", colour); case EffectControlPoint effect: - return new RowAttribute("effect", () => $"{(effect.KiaiMode ? "Kiai " : "")}{(effect.OmitFirstBarLine ? "NoBarLine " : "")}", colour); + return new RowAttribute("effect", () => string.Join(" ", + effect.KiaiMode ? "Kiai" : string.Empty, + effect.OmitFirstBarLine ? "NoBarLine" : string.Empty + ).Trim(), colour); case SampleControlPoint sample: return new RowAttribute("sample", () => $"{sample.SampleBank} {sample.SampleVolume}%", colour); @@ -206,7 +211,7 @@ namespace osu.Game.Screens.Edit.Timing Action = () => { selectedGroup.Value = controlGroup; - clock.SeekTo(controlGroup.Time); + clock.SeekSmoothlyTo(controlGroup.Time); }; } diff --git a/osu.Game/Screens/Edit/Timing/DifficultySection.cs b/osu.Game/Screens/Edit/Timing/DifficultySection.cs index b87b8961f8..9d80ca0b14 100644 --- a/osu.Game/Screens/Edit/Timing/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Timing/DifficultySection.cs @@ -18,7 +18,8 @@ namespace osu.Game.Screens.Edit.Timing { multiplierSlider = new SliderWithTextBoxInput("Speed Multiplier") { - Current = new DifficultyControlPoint().SpeedMultiplierBindable + Current = new DifficultyControlPoint().SpeedMultiplierBindable, + KeyboardStep = 0.1f } }); } diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs index f2f9f76143..10a5771520 100644 --- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs @@ -69,6 +69,15 @@ namespace osu.Game.Screens.Edit.Timing }, true); } + /// + /// A custom step value for each key press which actuates a change on this control. + /// + public float KeyboardStep + { + get => slider.KeyboardStep; + set => slider.KeyboardStep = value; + } + public Bindable Current { get => slider.Current; diff --git a/osu.Game/Screens/IHandlePresentBeatmap.cs b/osu.Game/Screens/IHandlePresentBeatmap.cs new file mode 100644 index 0000000000..60801fb3eb --- /dev/null +++ b/osu.Game/Screens/IHandlePresentBeatmap.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.Beatmaps; +using osu.Game.Rulesets; + +namespace osu.Game.Screens +{ + /// + /// Denotes a screen which can handle beatmap / ruleset selection via local logic. + /// This is used in the flow to handle cases which require custom logic, + /// for instance, if a lease is held on the Beatmap. + /// + public interface IHandlePresentBeatmap + { + /// + /// Invoked with a requested beatmap / ruleset for selection. + /// + /// The beatmap to be selected. + /// The ruleset to be selected. + void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset); + } +} diff --git a/osu.Game/Screens/IHasSubScreenStack.cs b/osu.Game/Screens/IHasSubScreenStack.cs new file mode 100644 index 0000000000..c5e2015109 --- /dev/null +++ b/osu.Game/Screens/IHasSubScreenStack.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Screens; + +namespace osu.Game.Screens +{ + /// + /// A screen which manages a nested stack of screens within itself. + /// + public interface IHasSubScreenStack + { + ScreenStack SubScreenStack { get; } + } +} diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index 329623e03a..ee8ef6926d 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -154,7 +154,7 @@ namespace osu.Game.Screens.Import Task.Factory.StartNew(async () => { - await game.Import(path); + await game.Import(path).ConfigureAwait(false); // some files will be deleted after successful import, so we want to refresh the view. Schedule(() => diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs index be6ed9700c..d956394ebb 100644 --- a/osu.Game/Screens/Menu/Button.cs +++ b/osu.Game/Screens/Menu/Button.cs @@ -45,8 +45,8 @@ namespace osu.Game.Screens.Menu public ButtonSystemState VisibleState = ButtonSystemState.TopLevel; private readonly Action clickAction; - private SampleChannel sampleClick; - private SampleChannel sampleHover; + private Sample sampleClick; + private Sample sampleHover; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index f400b2114b..81b1cb0bf1 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Menu private readonly List public virtual bool DisallowExternalBeatmapRulesetChanges => false; - private SampleChannel sampleExit; + private Sample sampleExit; protected virtual bool PlayResumeSound => true; diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index b53141e8fb..c56344a8fb 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -23,32 +23,6 @@ namespace osu.Game.Screens.Play /// public class BeatmapMetadataDisplay : Container { - private class MetadataLine : Container - { - public MetadataLine(string left, string right) - { - AutoSizeAxes = Axes.Both; - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopRight, - Margin = new MarginPadding { Right = 5 }, - Colour = OsuColour.Gray(0.8f), - Text = left, - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopLeft, - Margin = new MarginPadding { Left = 5 }, - Text = string.IsNullOrEmpty(right) ? @"-" : right, - } - }; - } - } - private readonly WorkingBeatmap beatmap; private readonly Bindable> mods; private readonly Drawable facade; @@ -99,7 +73,7 @@ namespace osu.Game.Screens.Play }), new OsuSpriteText { - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), Font = OsuFont.GetFont(size: 36, italics: true), Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -107,7 +81,7 @@ namespace osu.Game.Screens.Play }, new OsuSpriteText { - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), Font = OsuFont.GetFont(size: 26, italics: true), Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -144,15 +118,34 @@ namespace osu.Game.Screens.Play Bottom = 40 }, }, - new MetadataLine("Source", metadata.Source) + new GridContainer { - Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, - }, - new MetadataLine("Mapper", metadata.AuthorString) - { Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new MetadataLineLabel("Source"), + new MetadataLineInfo(metadata.Source) + }, + new Drawable[] + { + new MetadataLineLabel("Mapper"), + new MetadataLineInfo(metadata.AuthorString) + } + } }, new ModDisplay { @@ -168,5 +161,26 @@ namespace osu.Game.Screens.Play Loading = true; } + + private class MetadataLineLabel : OsuSpriteText + { + public MetadataLineLabel(string text) + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + Margin = new MarginPadding { Right = 5 }; + Colour = OsuColour.Gray(0.8f); + Text = text; + } + } + + private class MetadataLineInfo : OsuSpriteText + { + public MetadataLineInfo(string text) + { + Margin = new MarginPadding { Left = 5 }; + Text = string.IsNullOrEmpty(text) ? @"-" : text; + } + } } } diff --git a/osu.Game/Screens/Play/BreakTracker.cs b/osu.Game/Screens/Play/BreakTracker.cs index 51e21656e1..2f3673e91f 100644 --- a/osu.Game/Screens/Play/BreakTracker.cs +++ b/osu.Game/Screens/Play/BreakTracker.cs @@ -23,16 +23,17 @@ namespace osu.Game.Screens.Play /// public IBindable IsBreakTime => isBreakTime; - private readonly BindableBool isBreakTime = new BindableBool(); + private readonly BindableBool isBreakTime = new BindableBool(true); public IReadOnlyList Breaks { set { - isBreakTime.Value = false; - breaks = new PeriodTracker(value.Where(b => b.HasEffect) .Select(b => new Period(b.StartTime, b.EndTime - BreakOverlay.BREAK_FADE_DURATION))); + + if (IsLoaded) + updateBreakTime(); } } @@ -45,7 +46,11 @@ namespace osu.Game.Screens.Play protected override void Update() { base.Update(); + updateBreakTime(); + } + private void updateBreakTime() + { var time = Clock.CurrentTime; isBreakTime.Value = breaks?.IsInAny(time) == true diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 608f20affd..71bea2a145 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -34,7 +34,7 @@ namespace osu.Game.Screens.Play private const float duration = 2500; - private SampleChannel failSample; + private Sample failSample; public FailAnimation(DrawableRuleset drawableRuleset) { diff --git a/osu.Game/Screens/Play/GameplayBeatmap.cs b/osu.Game/Screens/Play/GameplayBeatmap.cs index 64894544f4..74fbe540fa 100644 --- a/osu.Game/Screens/Play/GameplayBeatmap.cs +++ b/osu.Game/Screens/Play/GameplayBeatmap.cs @@ -29,7 +29,11 @@ namespace osu.Game.Screens.Play public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; - public ControlPointInfo ControlPointInfo => PlayableBeatmap.ControlPointInfo; + public ControlPointInfo ControlPointInfo + { + get => PlayableBeatmap.ControlPointInfo; + set => PlayableBeatmap.ControlPointInfo = value; + } public List Breaks => PlayableBeatmap.Breaks; @@ -39,6 +43,8 @@ namespace osu.Game.Screens.Play public IEnumerable GetStatistics() => PlayableBeatmap.GetStatistics(); + public double GetMostCommonBeatLength() => PlayableBeatmap.GetMostCommonBeatLength(); + public IBeatmap Clone() => PlayableBeatmap.Clone(); private readonly Bindable lastJudgementResult = new Bindable(); diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index 7b94bf19ec..34efeab54c 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,6 +17,8 @@ namespace osu.Game.Screens.Play.HUD { private readonly Cached sorting = new Cached(); + public Bindable Expanded = new Bindable(); + public GameplayLeaderboard() { Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH; @@ -47,8 +50,7 @@ namespace osu.Game.Screens.Play.HUD { var drawable = new GameplayLeaderboardScore(user, isTracked) { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, + Expanded = { BindTarget = Expanded }, }; base.Add(drawable); diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index cb20deb272..10476e5565 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -20,16 +20,33 @@ namespace osu.Game.Screens.Play.HUD { public class GameplayLeaderboardScore : CompositeDrawable, ILeaderboardScore { - public const float EXTENDED_WIDTH = 255f; + public const float EXTENDED_WIDTH = regular_width + top_player_left_width_extension; private const float regular_width = 235f; + // a bit hand-wavy, but there's a lot of hard-coded paddings in each of the grid's internals. + private const float compact_width = 77.5f; + + private const float top_player_left_width_extension = 20f; + public const float PANEL_HEIGHT = 35f; public const float SHEAR_WIDTH = PANEL_HEIGHT * panel_shear; private const float panel_shear = 0.15f; + private const float rank_text_width = 35f; + + private const float score_components_width = 85f; + + private const float avatar_size = 25f; + + private const double panel_transition_duration = 500; + + private const double text_transition_duration = 200; + + public Bindable Expanded = new Bindable(); + private OsuSpriteText positionText, scoreText, accuracyText, comboText, usernameText; public BindableDouble TotalScore { get; } = new BindableDouble(); @@ -63,8 +80,15 @@ namespace osu.Game.Screens.Play.HUD private readonly bool trackedPlayer; private Container mainFillContainer; + private Box centralFill; + private Container backgroundPaddingAdjustContainer; + + private GridContainer gridContainer; + + private Container scoreComponents; + /// /// Creates a new . /// @@ -75,7 +99,8 @@ namespace osu.Game.Screens.Play.HUD User = user; this.trackedPlayer = trackedPlayer; - Size = new Vector2(EXTENDED_WIDTH, PANEL_HEIGHT); + AutoSizeAxes = Axes.X; + Height = PANEL_HEIGHT; } [BackgroundDependencyLoader] @@ -85,147 +110,167 @@ namespace osu.Game.Screens.Play.HUD InternalChildren = new Drawable[] { - mainFillContainer = new Container + new Container { - Width = regular_width, + AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Masking = true, - CornerRadius = 5f, - Shear = new Vector2(panel_shear, 0f), - Child = new Box + Margin = new MarginPadding { Left = top_player_left_width_extension }, + Children = new Drawable[] { - Alpha = 0.5f, - RelativeSizeAxes = Axes.Both, - } - }, - new GridContainer - { - Width = regular_width, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Absolute, 35f), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 85f), - }, - Content = new[] - { - new Drawable[] + backgroundPaddingAdjustContainer = new Container { - positionText = new OsuSpriteText + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Padding = new MarginPadding { Right = SHEAR_WIDTH / 2 }, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = Color4.White, - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.Bold), - Shadow = false, - }, - new Container - { - Padding = new MarginPadding { Horizontal = SHEAR_WIDTH / 3 }, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + mainFillContainer = new Container { - new Container + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5f, + Shear = new Vector2(panel_shear, 0f), + Children = new Drawable[] { - Masking = true, - CornerRadius = 5f, - Shear = new Vector2(panel_shear, 0f), - RelativeSizeAxes = Axes.Both, - Children = new[] + new Box { - centralFill = new Box - { - Alpha = 0.5f, - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex("3399cc"), - }, - } - }, - new FillFlowContainer - { - Padding = new MarginPadding { Left = SHEAR_WIDTH }, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4f, 0f), - Children = new Drawable[] - { - avatarContainer = new CircularContainer - { - Masking = true, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(25f), - Children = new Drawable[] - { - new Box - { - Name = "Placeholder while avatar loads", - Alpha = 0.3f, - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray4, - } - } - }, - usernameText = new OsuSpriteText - { - RelativeSizeAxes = Axes.X, - Width = 0.6f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = Color4.White, - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - Text = User?.Username, - Truncate = true, - Shadow = false, - } - } - }, - } - }, - new Container - { - Padding = new MarginPadding { Top = 2f, Right = 17.5f, Bottom = 5f }, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = Color4.White, - Children = new Drawable[] - { - scoreText = new OsuSpriteText - { - Spacing = new Vector2(-1f, 0f), - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold, fixedWidth: true), - Shadow = false, - }, - accuracyText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold, fixedWidth: true), - Spacing = new Vector2(-1f, 0f), - Shadow = false, - }, - comboText = new OsuSpriteText - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Spacing = new Vector2(-1f, 0f), - Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold, fixedWidth: true), - Shadow = false, + Alpha = 0.5f, + RelativeSizeAxes = Axes.Both, + }, }, }, } + }, + gridContainer = new GridContainer + { + RelativeSizeAxes = Axes.Y, + Width = compact_width, // will be updated by expanded state. + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, rank_text_width), + new Dimension(), + new Dimension(GridSizeMode.AutoSize, maxSize: score_components_width), + }, + Content = new[] + { + new Drawable[] + { + positionText = new OsuSpriteText + { + Padding = new MarginPadding { Right = SHEAR_WIDTH / 2 }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White, + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.Bold), + Shadow = false, + }, + new Container + { + Padding = new MarginPadding { Horizontal = SHEAR_WIDTH / 3 }, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = 5f, + Shear = new Vector2(panel_shear, 0f), + RelativeSizeAxes = Axes.Both, + Children = new[] + { + centralFill = new Box + { + Alpha = 0.5f, + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("3399cc"), + }, + } + }, + new FillFlowContainer + { + Padding = new MarginPadding { Left = SHEAR_WIDTH }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4f, 0f), + Children = new Drawable[] + { + avatarContainer = new CircularContainer + { + Masking = true, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(avatar_size), + Children = new Drawable[] + { + new Box + { + Name = "Placeholder while avatar loads", + Alpha = 0.3f, + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray4, + } + } + }, + usernameText = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Width = 0.6f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = Color4.White, + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + Text = User?.Username, + Truncate = true, + Shadow = false, + } + } + }, + } + }, + scoreComponents = new Container + { + Padding = new MarginPadding { Top = 2f, Right = 17.5f, Bottom = 5f }, + AlwaysPresent = true, // required to smoothly animate autosize after hidden early. + Masking = true, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = Color4.White, + Children = new Drawable[] + { + scoreText = new OsuSpriteText + { + Spacing = new Vector2(-1f, 0f), + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold, fixedWidth: true), + Shadow = false, + }, + accuracyText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-1f, 0f), + Shadow = false, + }, + comboText = new OsuSpriteText + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Spacing = new Vector2(-1f, 0f), + Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold, fixedWidth: true), + Shadow = false, + }, + }, + } + } + } } } - } + }, }; LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); @@ -241,18 +286,43 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); updateState(); + Expanded.BindValueChanged(changeExpandedState, true); + FinishTransforms(true); } - private const double panel_transition_duration = 500; + private void changeExpandedState(ValueChangedEvent expanded) + { + scoreComponents.ClearTransforms(); + + if (expanded.NewValue) + { + gridContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutQuint); + + scoreComponents.ResizeWidthTo(score_components_width, panel_transition_duration, Easing.OutQuint); + scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint); + + usernameText.FadeIn(panel_transition_duration, Easing.OutQuint); + } + else + { + gridContainer.ResizeWidthTo(compact_width, panel_transition_duration, Easing.OutQuint); + + scoreComponents.ResizeWidthTo(0, panel_transition_duration, Easing.OutQuint); + scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint); + + usernameText.FadeOut(text_transition_duration, Easing.OutQuint); + } + } private void updateState() { + bool widthExtension = false; + if (HasQuit.Value) { // we will probably want to display this in a better way once we have a design. // and also show states other than quit. - mainFillContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutElastic); panelColour = Color4.Gray; textColour = Color4.White; return; @@ -260,22 +330,29 @@ namespace osu.Game.Screens.Play.HUD if (scorePosition == 1) { - mainFillContainer.ResizeWidthTo(EXTENDED_WIDTH, panel_transition_duration, Easing.OutElastic); + widthExtension = true; panelColour = Color4Extensions.FromHex("7fcc33"); textColour = Color4.White; } else if (trackedPlayer) { - mainFillContainer.ResizeWidthTo(EXTENDED_WIDTH, panel_transition_duration, Easing.OutElastic); + widthExtension = true; panelColour = Color4Extensions.FromHex("ffd966"); textColour = Color4Extensions.FromHex("2e576b"); } else { - mainFillContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutElastic); panelColour = Color4Extensions.FromHex("3399cc"); textColour = Color4.White; } + + this.TransformTo(nameof(SizeContainerLeftPadding), widthExtension ? -top_player_left_width_extension : 0, panel_transition_duration, Easing.OutElastic); + } + + public float SizeContainerLeftPadding + { + get => backgroundPaddingAdjustContainer.Padding.Left; + set => backgroundPaddingAdjustContainer.Padding = new MarginPadding { Left = value }; } private Color4 panelColour @@ -287,8 +364,6 @@ namespace osu.Game.Screens.Play.HUD } } - private const double text_transition_duration = 200; - private Color4 textColour { set diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 387c0e587b..284ac899ed 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -88,11 +88,6 @@ namespace osu.Game.Screens.Play.HUD return base.OnMouseMove(e); } - public bool PauseOnFocusLost - { - set => button.PauseOnFocusLost = value; - } - protected override void Update() { base.Update(); @@ -120,8 +115,6 @@ namespace osu.Game.Screens.Play.HUD public Action HoverGained; public Action HoverLost; - private readonly IBindable gameActive = new Bindable(true); - [BackgroundDependencyLoader] private void load(OsuColour colours, Framework.Game game) { @@ -164,14 +157,6 @@ namespace osu.Game.Screens.Play.HUD }; bind(); - - gameActive.BindTo(game.IsActive); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - gameActive.BindValueChanged(_ => updateActive(), true); } private void bind() @@ -221,31 +206,6 @@ namespace osu.Game.Screens.Play.HUD base.OnHoverLost(e); } - private bool pauseOnFocusLost = true; - - public bool PauseOnFocusLost - { - set - { - if (pauseOnFocusLost == value) - return; - - pauseOnFocusLost = value; - if (IsLoaded) - updateActive(); - } - } - - private void updateActive() - { - if (!pauseOnFocusLost || IsPaused.Value) return; - - if (gameActive.Value) - AbortConfirm(); - else - BeginConfirm(); - } - public bool OnPressed(GlobalAction action) { switch (action) diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 4784bca7dd..81183a425a 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -84,14 +84,15 @@ namespace osu.Game.Screens.Play.HUD { InternalChildren = new[] { - displayedCountSpriteText = createSpriteText().With(s => - { - s.Alpha = 0; - }), popOutCount = createSpriteText().With(s => { s.Alpha = 0; s.Margin = new MarginPadding(0.05f); + s.Blending = BlendingParameters.Additive; + }), + displayedCountSpriteText = createSpriteText().With(s => + { + s.Alpha = 0; }) }; diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index 68d019bf71..cffdb21fb8 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -72,19 +72,6 @@ namespace osu.Game.Screens.Play.HUD } }, }; - - Current.ValueChanged += mods => - { - iconsContainer.Clear(); - - foreach (Mod mod in mods.NewValue) - { - iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); - } - - if (IsLoaded) - appearTransform(); - }; } protected override void Dispose(bool isDisposing) @@ -97,7 +84,19 @@ namespace osu.Game.Screens.Play.HUD { base.LoadComplete(); - appearTransform(); + Current.BindValueChanged(mods => + { + iconsContainer.Clear(); + + if (mods.NewValue != null) + { + foreach (Mod mod in mods.NewValue) + iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); + + appearTransform(); + } + }, true); + iconsContainer.FadeInFromZero(fade_duration, Easing.OutQuint); } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index d4ce542a67..a3d27c4e71 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -53,8 +53,6 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(OsuConfigManager config, IAPIProvider api) { - streamingClient.OnNewFrames += handleIncomingFrames; - foreach (var userId in playingUsers) { streamingClient.WatchUser(userId); @@ -90,6 +88,9 @@ namespace osu.Game.Screens.Play.HUD playingUsers.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); playingUsers.BindCollectionChanged(usersChanged); + + // this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer). + streamingClient.OnNewFrames += handleIncomingFrames; } private void usersChanged(object sender, NotifyCollectionChangedEventArgs e) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 1fcbed7ef7..0e221351aa 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -59,6 +59,8 @@ namespace osu.Game.Screens.Play // We are managing our own adjustments (see OnEntering/OnExiting). public override bool AllowRateAdjustments => false; + private readonly IBindable gameActive = new Bindable(true); + private readonly Bindable samplePlaybackDisabled = new Bindable(); /// @@ -91,7 +93,7 @@ namespace osu.Game.Screens.Play [Resolved] private MusicController musicController { get; set; } - private SampleChannel sampleRestart; + private Sample sampleRestart; public BreakOverlay BreakOverlay; @@ -154,6 +156,8 @@ namespace osu.Game.Screens.Play // replays should never be recorded or played back when autoplay is enabled if (!Mods.Value.Any(m => m is ModAutoplay)) PrepareReplay(); + + gameActive.BindValueChanged(_ => updatePauseOnFocusLostState(), true); } [CanBeNull] @@ -170,7 +174,7 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuConfigManager config, OsuGame game) + private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game) { Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); @@ -187,7 +191,10 @@ namespace osu.Game.Screens.Play mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); if (game != null) - LocalUserPlaying.BindTo(game.LocalUserPlaying); + gameActive.BindTo(game.IsActive); + + if (game is OsuGame osuGame) + LocalUserPlaying.BindTo(osuGame.LocalUserPlaying); DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); @@ -258,8 +265,6 @@ namespace osu.Game.Screens.Play DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState()); - DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updatePauseOnFocusLostState(), true); - // bind clock into components that require it DrawableRuleset.IsPaused.BindTo(GameplayClockContainer.IsPaused); @@ -334,7 +339,7 @@ namespace osu.Game.Screens.Play { HoldToQuit = { - Action = performUserRequestedExit, + Action = () => PerformExit(true), IsPaused = { BindTarget = GameplayClockContainer.IsPaused } }, PlayerSettingsOverlay = { PlaybackSettings = { UserPlaybackRate = { BindTarget = GameplayClockContainer.UserPlaybackRate } } }, @@ -353,19 +358,19 @@ namespace osu.Game.Screens.Play }, skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { - RequestSkip = GameplayClockContainer.Skip + RequestSkip = performUserRequestedSkip }, FailOverlay = new FailOverlay { OnRetry = Restart, - OnQuit = performUserRequestedExit, + OnQuit = () => PerformExit(true), }, PauseOverlay = new PauseOverlay { OnResume = Resume, Retries = RestartCount, OnRetry = Restart, - OnQuit = performUserRequestedExit, + OnQuit = () => PerformExit(true), }, new HotkeyExitOverlay { @@ -374,7 +379,7 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) return; fadeOut(true); - PerformExit(true); + PerformExit(false); }, }, failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, }, @@ -420,10 +425,21 @@ namespace osu.Game.Screens.Play samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.GameplayClock.IsPaused.Value; } - private void updatePauseOnFocusLostState() => - HUDOverlay.HoldToQuit.PauseOnFocusLost = PauseOnFocusLost - && !DrawableRuleset.HasReplayLoaded.Value - && !breakTracker.IsBreakTime.Value; + private void updatePauseOnFocusLostState() + { + if (!PauseOnFocusLost || !pausingSupportedByCurrentState || breakTracker.IsBreakTime.Value) + return; + + if (gameActive.Value == false) + { + bool paused = Pause(); + + // if the initial pause could not be satisfied, the pause cooldown may be active. + // reschedule the pause attempt until it can be achieved. + if (!paused) + Scheduler.AddOnce(updatePauseOnFocusLostState); + } + } private IBeatmap loadPlayableBeatmap() { @@ -469,37 +485,58 @@ namespace osu.Game.Screens.Play /// /// Exits the . /// - /// - /// Whether the exit is requested by the user, or a higher-level game component. - /// Pausing is allowed only in the former case. + /// + /// Whether the pause or fail dialog should be shown before performing an exit. + /// If true and a dialog is not yet displayed, the exit will be blocked the the relevant dialog will display instead. /// - protected void PerformExit(bool userRequested) + protected void PerformExit(bool showDialogFirst) { // if a restart has been requested, cancel any pending completion (user has shown intent to restart). completionProgressDelegate?.Cancel(); - ValidForResume = false; - - if (!this.IsCurrentScreen()) return; - - if (userRequested) - performUserRequestedExit(); - else - this.Exit(); - } - - private void performUserRequestedExit() - { - if (ValidForResume && HasFailed && !FailOverlay.IsPresent) + // there is a chance that the exit was performed after the transition to results has started. + // we want to give the user what they want, so forcefully return to this screen (to proceed with the upwards exit process). + if (!this.IsCurrentScreen()) { - failAnimation.FinishTransforms(true); + ValidForResume = false; + this.MakeCurrent(); return; } - if (canPause) - Pause(); - else - this.Exit(); + bool pauseOrFailDialogVisible = + PauseOverlay.State.Value == Visibility.Visible || FailOverlay.State.Value == Visibility.Visible; + + if (showDialogFirst && !pauseOrFailDialogVisible) + { + // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion). + if (ValidForResume && HasFailed) + { + failAnimation.FinishTransforms(true); + return; + } + + // there's a chance the pausing is not supported in the current state, at which point immediate exit should be preferred. + if (pausingSupportedByCurrentState) + { + // in the case a dialog needs to be shown, attempt to pause and show it. + // this may fail (see internal checks in Pause()) but the fail cases are temporary, so don't fall through to Exit(). + Pause(); + return; + } + } + + this.Exit(); + } + + private void performUserRequestedSkip() + { + // user requested skip + // disable sample playback to stop currently playing samples and perform skip + samplePlaybackDisabled.Value = true; + GameplayClockContainer.Skip(); + + // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state + updateSampleDisabledState(); } /// @@ -518,10 +555,7 @@ namespace osu.Game.Screens.Play sampleRestart?.Play(); RestartRequested?.Invoke(); - if (this.IsCurrentScreen()) - PerformExit(true); - else - this.MakeCurrent(); + PerformExit(false); } private ScheduledDelegate completionProgressDelegate; @@ -558,7 +592,7 @@ namespace osu.Game.Screens.Play try { - await SubmitScore(score); + await SubmitScore(score).ConfigureAwait(false); } catch (Exception ex) { @@ -567,7 +601,7 @@ namespace osu.Game.Screens.Play try { - await ImportScore(score); + await ImportScore(score).ConfigureAwait(false); } catch (Exception ex) { @@ -647,18 +681,21 @@ namespace osu.Game.Screens.Play private double? lastPauseActionTime; - private bool canPause => + protected bool PauseCooldownActive => + lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown; + + /// + /// A set of conditionals which defines whether the current game state and configuration allows for + /// pausing to be attempted via . If false, the game should generally exit if a user pause + /// is attempted. + /// + private bool pausingSupportedByCurrentState => // must pass basic screen conditions (beatmap loaded, instance allows pause) LoadedBeatmapSuccessfully && Configuration.AllowPause && ValidForResume // replays cannot be paused and exit immediately && !DrawableRuleset.HasReplayLoaded.Value // cannot pause if we are already in a fail state - && !HasFailed - // cannot pause if already paused (or in a cooldown state) unless we are in a resuming state. - && (IsResuming || (GameplayClockContainer.IsPaused.Value == false && !pauseCooldownActive)); - - private bool pauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown; + && !HasFailed; private bool canResume => // cannot resume from a non-paused state @@ -668,9 +705,12 @@ namespace osu.Game.Screens.Play // already resuming && !IsResuming; - public void Pause() + public bool Pause() { - if (!canPause) return; + if (!pausingSupportedByCurrentState) return false; + + if (!IsResuming && PauseCooldownActive) + return false; if (IsResuming) { @@ -681,6 +721,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Stop(); PauseOverlay.Show(); lastPauseActionTime = GameplayClockContainer.GameplayClock.CurrentTime; + return true; } public void Resume() @@ -789,14 +830,6 @@ namespace osu.Game.Screens.Play return true; } - // ValidForResume is false when restarting - if (ValidForResume) - { - if (pauseCooldownActive && !GameplayClockContainer.IsPaused.Value) - // still want to block if we are within the cooldown period and not already paused. - return true; - } - // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. GameplayClockContainer?.StopUsingBeatmapClock(); diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 5b4bd11216..7d906cdc5b 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 92b304de91..3f214e49d9 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -230,7 +230,7 @@ namespace osu.Game.Screens.Play private Box background; private AspectContainer aspect; - private SampleChannel sampleConfirm; + private Sample sampleConfirm; public Button() { diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index ff6203bc25..6a6b39b61c 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -101,7 +101,7 @@ namespace osu.Game.Screens.Ranking.Expanded { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, Truncate = true, @@ -110,7 +110,7 @@ namespace osu.Game.Screens.Ranking.Expanded { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, Truncate = true, diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 76b549da1a..9bc696948f 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -15,6 +15,8 @@ namespace osu.Game.Screens.Ranking { public class SoloResultsScreen : ResultsScreen { + private GetScoresRequest getScoreRequest; + [Resolved] private RulesetStore rulesets { get; set; } @@ -28,9 +30,16 @@ namespace osu.Game.Screens.Ranking if (Score.Beatmap.OnlineBeatmapID == null || Score.Beatmap.Status <= BeatmapSetOnlineStatus.Pending) return null; - var req = new GetScoresRequest(Score.Beatmap, Score.Ruleset); - req.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineScoreID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets))); - return req; + getScoreRequest = new GetScoresRequest(Score.Beatmap, Score.Ruleset); + getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineScoreID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets))); + return getScoreRequest; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + getScoreRequest?.Cancel(); } } } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 7ba6e400bf..b05b7aeb32 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -918,15 +918,10 @@ namespace osu.Game.Screens.Select } } - protected class CarouselScrollContainer : OsuScrollContainer + protected class CarouselScrollContainer : UserTrackingScrollContainer { private bool rightMouseScrollBlocked; - /// - /// Whether the last scroll event was user triggered, directly on the scroll container. - /// - public bool UserScrolling { get; private set; } - public CarouselScrollContainer() { // size is determined by the carousel itself, due to not all content necessarily being loaded. @@ -936,18 +931,6 @@ namespace osu.Game.Screens.Select Masking = false; } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) - { - UserScrolling = true; - base.OnUserScroll(value, animated, distanceDecay); - } - - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) - { - UserScrolling = false; - base.ScrollTo(value, animated, distanceDecay); - } - protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button == MouseButton.Right) diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 04c1f6efe4..4069dc82ed 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -25,6 +24,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -38,12 +38,16 @@ namespace osu.Game.Screens.Select private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGE_HEIGHT, 0); - private readonly IBindable ruleset = new Bindable(); + [Resolved] + private IBindable ruleset { get; set; } + + [Resolved] + private IBindable> mods { get; set; } [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - private IBindable beatmapDifficulty; + private IBindable beatmapDifficulty; protected BufferedWedgeInfo Info; @@ -63,11 +67,10 @@ namespace osu.Game.Screens.Select }; } - [BackgroundDependencyLoader(true)] - private void load([CanBeNull] Bindable parentRuleset) + [BackgroundDependencyLoader] + private void load() { - ruleset.BindTo(parentRuleset); - ruleset.ValueChanged += _ => updateDisplay(); + ruleset.BindValueChanged(_ => updateDisplay()); } protected override void PopIn() @@ -132,7 +135,7 @@ namespace osu.Game.Screens.Select return; } - LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value, beatmapDifficulty.Value) + LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty()) { Shear = -Shear, Depth = Info?.Depth + 1 ?? 0 @@ -160,20 +163,25 @@ namespace osu.Game.Screens.Select public OsuSpriteText ArtistLabel { get; private set; } public BeatmapSetOnlineStatusPill StatusPill { get; private set; } public FillFlowContainer MapperContainer { get; private set; } - public FillFlowContainer InfoLabelContainer { get; private set; } private ILocalisedBindableString titleBinding; private ILocalisedBindableString artistBinding; + private FillFlowContainer infoLabelContainer; + private Container bpmLabelContainer; private readonly WorkingBeatmap beatmap; private readonly RulesetInfo ruleset; + private readonly IReadOnlyList mods; private readonly StarDifficulty starDifficulty; - public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset, StarDifficulty difficulty) + private ModSettingChangeTracker settingChangeTracker; + + public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty) : base(pixelSnapping: true) { this.beatmap = beatmap; ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; + this.mods = mods; starDifficulty = difficulty; } @@ -184,11 +192,10 @@ namespace osu.Game.Screens.Select var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); CacheDrawnFrameBuffer = true; - RelativeSizeAxes = Axes.Both; - titleBinding = localisation.GetLocalisedString(new LocalisedString((metadata.TitleUnicode, metadata.Title))); - artistBinding = localisation.GetLocalisedString(new LocalisedString((metadata.ArtistUnicode, metadata.Artist))); + titleBinding = localisation.GetLocalisedString(new RomanisableString(metadata.TitleUnicode, metadata.Title)); + artistBinding = localisation.GetLocalisedString(new RomanisableString(metadata.ArtistUnicode, metadata.Artist)); Children = new Drawable[] { @@ -302,12 +309,11 @@ namespace osu.Game.Screens.Select AutoSizeAxes = Axes.Both, Children = getMapper(metadata) }, - InfoLabelContainer = new FillFlowContainer + infoLabelContainer = new FillFlowContainer { Margin = new MarginPadding { Top = 20 }, Spacing = new Vector2(20, 0), AutoSizeAxes = Axes.Both, - Children = getInfoLabels() } } } @@ -319,6 +325,8 @@ namespace osu.Game.Screens.Select // no difficulty means it can't have a status to show if (beatmapInfo.Version == null) StatusPill.Hide(); + + addInfoLabels(); } private static Drawable createStarRatingDisplay(StarDifficulty difficulty) => difficulty.Stars > 0 @@ -335,63 +343,91 @@ namespace osu.Game.Screens.Select ForceRedraw(); } - private InfoLabel[] getInfoLabels() + private void addInfoLabels() { - var b = beatmap.Beatmap; + if (beatmap.Beatmap?.HitObjects?.Any() != true) + return; - List labels = new List(); - - if (b?.HitObjects?.Any() == true) + infoLabelContainer.Children = new Drawable[] { - labels.Add(new InfoLabel(new BeatmapStatistic + new InfoLabel(new BeatmapStatistic { Name = "Length", CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = TimeSpan.FromMilliseconds(b.BeatmapInfo.Length).ToString(@"m\:ss"), - })); - - labels.Add(new InfoLabel(new BeatmapStatistic + Content = TimeSpan.FromMilliseconds(beatmap.BeatmapInfo.Length).ToString(@"m\:ss"), + }), + bpmLabelContainer = new Container { - Name = "BPM", - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), - Content = getBPMRange(b), - })); + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(20, 0), + Children = getRulesetInfoLabels() + } + }; + + settingChangeTracker = new ModSettingChangeTracker(mods); + settingChangeTracker.SettingChanged += _ => refreshBPMLabel(); + + refreshBPMLabel(); + } + + private InfoLabel[] getRulesetInfoLabels() + { + try + { + IBeatmap playableBeatmap; try { - IBeatmap playableBeatmap; - - try - { - // Try to get the beatmap with the user's ruleset - playableBeatmap = beatmap.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()); - } - - labels.AddRange(playableBeatmap.GetStatistics().Select(s => new InfoLabel(s))); + // Try to get the beatmap with the user's ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); } - catch (Exception e) + catch (BeatmapInvalidForRulesetException) { - Logger.Error(e, "Could not load beatmap successfully!"); + // Can't be converted to the user's ruleset, so use the beatmap's own ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); } + + return playableBeatmap.GetStatistics().Select(s => new InfoLabel(s)).ToArray(); + } + catch (Exception e) + { + Logger.Error(e, "Could not load beatmap successfully!"); } - return labels.ToArray(); + return Array.Empty(); } - private string getBPMRange(IBeatmap beatmap) + private void refreshBPMLabel() { - double bpmMax = beatmap.ControlPointInfo.BPMMaximum; - double bpmMin = beatmap.ControlPointInfo.BPMMinimum; + var b = beatmap.Beatmap; + if (b == null) + return; - if (Precision.AlmostEquals(bpmMin, bpmMax)) - return $"{bpmMin:0}"; + // this doesn't consider mods which apply variable rates, yet. + double rate = 1; + foreach (var mod in mods.OfType()) + rate = mod.ApplyToRate(0, rate); - return $"{bpmMin:0}-{bpmMax:0} (mostly {beatmap.ControlPointInfo.BPMMode:0})"; + double bpmMax = b.ControlPointInfo.BPMMaximum * rate; + double bpmMin = b.ControlPointInfo.BPMMinimum * rate; + double mostCommonBPM = 60000 / b.GetMostCommonBeatLength() * rate; + + string labelText = Precision.AlmostEquals(bpmMin, bpmMax) + ? $"{bpmMin:0}" + : $"{bpmMin:0}-{bpmMax:0} (mostly {mostCommonBPM:0})"; + + bpmLabelContainer.Child = new InfoLabel(new BeatmapStatistic + { + Name = "BPM", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), + Content = labelText + }); + + ForceRedraw(); } private OsuSpriteText[] getMapper(BeatmapMetadata metadata) @@ -414,6 +450,12 @@ namespace osu.Game.Screens.Select }; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + settingChangeTracker?.Dispose(); + } + public class InfoLabel : Container, IHasTooltip { public string TooltipText { get; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 1aab50037a..521b90202d 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -73,6 +73,9 @@ namespace osu.Game.Screens.Select.Carousel if (match) match &= criteria.Collection?.Beatmaps.Contains(Beatmap) ?? true; + if (match && criteria.RulesetCriteria != null) + match &= criteria.RulesetCriteria.Matches(Beatmap); + Filtered.Value = !match; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs index f1120f55a6..2fbf64de29 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -20,16 +21,17 @@ namespace osu.Game.Screens.Select.Carousel { public class CarouselHeader : Container { - private SampleChannel sampleHover; - - private readonly Box hoverLayer; - public Container BorderContainer; public readonly Bindable State = new Bindable(CarouselItemState.NotSelected); + private readonly HoverLayer hoverLayer; + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + private const float corner_radius = 10; + private const float border_thickness = 2.5f; + public CarouselHeader() { RelativeSizeAxes = Axes.X; @@ -39,28 +41,16 @@ namespace osu.Game.Screens.Select.Carousel { RelativeSizeAxes = Axes.Both, Masking = true, - CornerRadius = 10, + CornerRadius = corner_radius, BorderColour = new Color4(221, 255, 255, 255), Children = new Drawable[] { Content, - hoverLayer = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Blending = BlendingParameters.Additive, - }, + hoverLayer = new HoverLayer() } }; } - [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) - { - sampleHover = audio.Samples.Get($@"SongSelect/song-ping-variation-{RNG.Next(1, 5)}"); - hoverLayer.Colour = colours.Blue.Opacity(0.1f); - } - protected override void LoadComplete() { base.LoadComplete(); @@ -74,6 +64,8 @@ namespace osu.Game.Screens.Select.Carousel { case CarouselItemState.Collapsed: case CarouselItemState.NotSelected: + hoverLayer.InsetForBorder = false; + BorderContainer.BorderThickness = 0; BorderContainer.EdgeEffect = new EdgeEffectParameters { @@ -85,7 +77,9 @@ namespace osu.Game.Screens.Select.Carousel break; case CarouselItemState.Selected: - BorderContainer.BorderThickness = 2.5f; + hoverLayer.InsetForBorder = true; + + BorderContainer.BorderThickness = border_thickness; BorderContainer.EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, @@ -97,18 +91,70 @@ namespace osu.Game.Screens.Select.Carousel } } - protected override bool OnHover(HoverEvent e) + public class HoverLayer : HoverSampleDebounceComponent { - sampleHover?.Play(); + private Sample sampleHover; - hoverLayer.FadeIn(100, Easing.OutQuint); - return base.OnHover(e); - } + private Box box; - protected override void OnHoverLost(HoverLostEvent e) - { - hoverLayer.FadeOut(1000, Easing.OutQuint); - base.OnHoverLost(e); + public HoverLayer() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuColour colours) + { + InternalChild = box = new Box + { + Colour = colours.Blue.Opacity(0.1f), + Alpha = 0, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + }; + + sampleHover = audio.Samples.Get("SongSelect/song-ping"); + } + + public bool InsetForBorder + { + set + { + if (value) + { + // apply same border as above to avoid applying additive overlay to it (and blowing out the colour). + Masking = true; + CornerRadius = corner_radius; + BorderThickness = border_thickness; + } + else + { + BorderThickness = 0; + CornerRadius = 0; + Masking = false; + } + } + } + + protected override bool OnHover(HoverEvent e) + { + box.FadeIn(100, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + box.FadeOut(1000, Easing.OutQuint); + base.OnHoverLost(e); + } + + public override void PlayHoverSample() + { + if (sampleHover == null) return; + + sampleHover.Frequency.Value = 0.90 + RNG.NextDouble(0.2); + sampleHover.Play(); + } } } } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index e66469ff8d..633ef9297e 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -63,7 +63,7 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } - private IBindable starDifficultyBindable; + private IBindable starDifficultyBindable; private CancellationTokenSource starDifficultyCancellationSource; public DrawableCarouselBeatmap(CarouselBeatmap panel) @@ -217,7 +217,10 @@ namespace osu.Game.Screens.Select.Carousel { // We've potentially cancelled the computation above so a new bindable is required. starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); - starDifficultyBindable.BindValueChanged(d => starCounter.Current = (float)d.NewValue.Stars, true); + starDifficultyBindable.BindValueChanged(d => + { + starCounter.Current = (float)(d.NewValue?.Stars ?? 0); + }, true); } base.ApplyState(); diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index 4e8d27f14d..23a02547b2 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -41,13 +41,13 @@ namespace osu.Game.Screens.Select.Carousel { new OsuSpriteText { - Text = new LocalisedString((beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title)), + Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true), Shadow = true, }, new OsuSpriteText { - Text = new LocalisedString((beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist)), + Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), Shadow = true, }, diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index 44d908fc46..b0084735b1 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -15,10 +15,11 @@ using System.Collections.Generic; using osu.Game.Rulesets.Mods; using System.Linq; using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets; namespace osu.Game.Screens.Select.Details @@ -83,32 +84,22 @@ namespace osu.Game.Screens.Select.Details mods.BindValueChanged(modsChanged, true); } - private readonly List references = new List(); + private ModSettingChangeTracker modSettingChangeTracker; + private ScheduledDelegate debouncedStatisticsUpdate; private void modsChanged(ValueChangedEvent> mods) { - // TODO: find a more permanent solution for this if/when it is needed in other components. - // this is generating drawables for the only purpose of storing bindable references. - foreach (var r in references) - r.Dispose(); + modSettingChangeTracker?.Dispose(); - references.Clear(); - - ScheduledDelegate debounce = null; - - foreach (var mod in mods.NewValue.OfType()) + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += m => { - foreach (var setting in mod.CreateSettingsControls().OfType()) - { - setting.SettingChanged += () => - { - debounce?.Cancel(); - debounce = Scheduler.AddDelayed(updateStatistics, 100); - }; + if (!(m is IApplicableToDifficulty)) + return; - references.Add(setting); - } - } + debouncedStatisticsUpdate?.Cancel(); + debouncedStatisticsUpdate = Scheduler.AddDelayed(updateStatistics, 100); + }; updateStatistics(); } @@ -148,8 +139,6 @@ namespace osu.Game.Screens.Select.Details updateStarDifficulty(); } - private IBindable normalStarDifficulty; - private IBindable moddedStarDifficulty; private CancellationTokenSource starDifficultyCancellationSource; private void updateStarDifficulty() @@ -161,18 +150,19 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource = new CancellationTokenSource(); - normalStarDifficulty = difficultyCache.GetBindableDifficulty(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); - moddedStarDifficulty = difficultyCache.GetBindableDifficulty(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var normalStarDifficulty = difficultyCache.GetDifficultyAsync(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); + var moddedStarDifficulty = difficultyCache.GetDifficultyAsync(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); - normalStarDifficulty.BindValueChanged(_ => updateDisplay()); - moddedStarDifficulty.BindValueChanged(_ => updateDisplay(), true); - - void updateDisplay() => starDifficulty.Value = ((float)normalStarDifficulty.Value.Stars, (float)moddedStarDifficulty.Value.Stars); + Task.WhenAll(normalStarDifficulty, moddedStarDifficulty).ContinueWith(_ => Schedule(() => + { + starDifficulty.Value = ((float)normalStarDifficulty.Result.Stars, (float)moddedStarDifficulty.Result.Stars); + }), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + modSettingChangeTracker?.Dispose(); starDifficultyCancellationSource?.Cancel(); } @@ -190,7 +180,7 @@ namespace osu.Game.Screens.Select.Details [Resolved] private OsuColour colours { get; set; } - public string Title + public LocalisableString Title { get => name.Text; set => name.Text = value; diff --git a/osu.Game/Screens/Select/Filter/Operator.cs b/osu.Game/Screens/Select/Filter/Operator.cs new file mode 100644 index 0000000000..706daf631f --- /dev/null +++ b/osu.Game/Screens/Select/Filter/Operator.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.Select.Filter +{ + /// + /// Defines logical operators that can be used in the song select search box keyword filters. + /// + public enum Operator + { + Less, + LessOrEqual, + Equal, + GreaterOrEqual, + Greater + } +} diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 952a5d1eaa..298b6e49bd 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.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.Bindables; using osu.Framework.Graphics; @@ -36,6 +37,8 @@ namespace osu.Game.Screens.Select public FilterCriteria CreateCriteria() { + Debug.Assert(ruleset.Value.ID != null); + var query = searchTextBox.Text; var criteria = new FilterCriteria @@ -44,7 +47,7 @@ namespace osu.Game.Screens.Select Sort = sortMode.Value, AllowConvertedBeatmaps = showConverted.Value, Ruleset = ruleset.Value, - Collection = collectionDropdown?.Current.Value.Collection + Collection = collectionDropdown?.Current.Value?.Collection }; if (!minimumStars.IsDefault) @@ -53,6 +56,8 @@ namespace osu.Game.Screens.Select if (!maximumStars.IsDefault) criteria.UserStarDifficulty.Max = maximumStars.Value; + criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); return criteria; } diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 7bddb3e51b..208048380a 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -8,6 +8,7 @@ using JetBrains.Annotations; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Rulesets; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select.Filter; namespace osu.Game.Screens.Select @@ -69,6 +70,9 @@ namespace osu.Game.Screens.Select [CanBeNull] public BeatmapCollection Collection; + [CanBeNull] + public IRulesetFilterCriteria RulesetCriteria { get; set; } + public struct OptionalRange : IEquatable> where T : struct { diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 4b6b3be45c..ea7f233bea 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -5,13 +5,17 @@ using System; using System.Globalization; using System.Text.RegularExpressions; using osu.Game.Beatmaps; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Screens.Select { - internal static class FilterQueryParser + /// + /// Utility class used for parsing song select filter queries entered via the search box. + /// + public static class FilterQueryParser { private static readonly Regex query_syntax_regex = new Regex( - @"\b(?stars|ar|dr|hp|cs|divisor|length|objects|bpm|status|creator|artist)(?[=:><]+)(?("".*"")|(\S*))", + @"\b(?\w+)(?(:|=|(>|<)(:|=)?))(?("".*"")|(\S*))", RegexOptions.Compiled | RegexOptions.IgnoreCase); internal static void ApplyQueries(FilterCriteria criteria, string query) @@ -19,62 +23,81 @@ namespace osu.Game.Screens.Select foreach (Match match in query_syntax_regex.Matches(query)) { var key = match.Groups["key"].Value.ToLower(); - var op = match.Groups["op"].Value; + var op = parseOperator(match.Groups["op"].Value); var value = match.Groups["value"].Value; - parseKeywordCriteria(criteria, key, value, op); - - query = query.Replace(match.ToString(), ""); + if (tryParseKeywordCriteria(criteria, key, value, op)) + query = query.Replace(match.ToString(), ""); } criteria.SearchText = query; } - private static void parseKeywordCriteria(FilterCriteria criteria, string key, string value, string op) + private static bool tryParseKeywordCriteria(FilterCriteria criteria, string key, string value, Operator op) { switch (key) { - case "stars" when parseFloatWithPoint(value, out var stars): - updateCriteriaRange(ref criteria.StarDifficulty, op, stars, 0.01f / 2); - break; + case "stars": + return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0.01d / 2); - case "ar" when parseFloatWithPoint(value, out var ar): - updateCriteriaRange(ref criteria.ApproachRate, op, ar, 0.1f / 2); - break; + case "ar": + return TryUpdateCriteriaRange(ref criteria.ApproachRate, op, value); - case "dr" when parseFloatWithPoint(value, out var dr): - case "hp" when parseFloatWithPoint(value, out dr): - updateCriteriaRange(ref criteria.DrainRate, op, dr, 0.1f / 2); - break; + case "dr": + case "hp": + return TryUpdateCriteriaRange(ref criteria.DrainRate, op, value); - case "cs" when parseFloatWithPoint(value, out var cs): - updateCriteriaRange(ref criteria.CircleSize, op, cs, 0.1f / 2); - break; + case "cs": + return TryUpdateCriteriaRange(ref criteria.CircleSize, op, value); - case "bpm" when parseDoubleWithPoint(value, out var bpm): - updateCriteriaRange(ref criteria.BPM, op, bpm, 0.01d / 2); - break; + case "bpm": + return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2); - case "length" when parseDoubleWithPoint(value.TrimEnd('m', 's', 'h'), out var length): - var scale = getLengthScale(value); - updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0); - break; + case "length": + return tryUpdateLengthRange(criteria, op, value); - case "divisor" when parseInt(value, out var divisor): - updateCriteriaRange(ref criteria.BeatDivisor, op, divisor); - break; + case "divisor": + return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt); - case "status" when Enum.TryParse(value, true, out var statusValue): - updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue); - break; + case "status": + return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value, + (string s, out BeatmapSetOnlineStatus val) => Enum.TryParse(value, true, out val)); case "creator": - updateCriteriaText(ref criteria.Creator, op, value); - break; + return TryUpdateCriteriaText(ref criteria.Creator, op, value); case "artist": - updateCriteriaText(ref criteria.Artist, op, value); - break; + return TryUpdateCriteriaText(ref criteria.Artist, op, value); + + default: + return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; + } + } + + private static Operator parseOperator(string value) + { + switch (value) + { + case "=": + case ":": + return Operator.Equal; + + case "<": + return Operator.Less; + + case "<=": + case "<:": + return Operator.LessOrEqual; + + case ">": + return Operator.Greater; + + case ">=": + case ">:": + return Operator.GreaterOrEqual; + + default: + throw new ArgumentOutOfRangeException(nameof(value), $"Unsupported operator {value}"); } } @@ -84,129 +107,203 @@ namespace osu.Game.Screens.Select value.EndsWith('m') ? 60000 : value.EndsWith('h') ? 3600000 : 1000; - private static bool parseFloatWithPoint(string value, out float result) => + private static bool tryParseFloatWithPoint(string value, out float result) => float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); - private static bool parseDoubleWithPoint(string value, out double result) => + private static bool tryParseDoubleWithPoint(string value, out double result) => double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); - private static bool parseInt(string value, out int result) => + private static bool tryParseInt(string value, out int result) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result); - private static void updateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, string op, string value) + /// + /// Attempts to parse a keyword filter with the specified and textual . + /// If the value indicates a valid textual filter, the function returns true and the resulting data is stored into + /// . + /// + /// The to store the parsed data into, if successful. + /// + /// The operator for the keyword filter. + /// Only is valid for textual filters. + /// + /// The value of the keyword filter. + public static bool TryUpdateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, Operator op, string value) { switch (op) { - case "=": - case ":": + case Operator.Equal: textFilter.SearchTerm = value.Trim('"'); - break; + return true; + + default: + return false; } } - private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, float value, float tolerance = 0.05f) + /// + /// Attempts to parse a keyword filter of type + /// from the specified and . + /// If can be parsed as a , the function returns true + /// and the resulting range constraint is stored into . + /// + /// + /// The -typed + /// to store the parsed data into, if successful. + /// + /// The operator for the keyword filter. + /// The value of the keyword filter. + /// Allowed tolerance of the parsed range boundary value. + public static bool TryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, string val, float tolerance = 0.05f) + => tryParseFloatWithPoint(val, out float value) && tryUpdateCriteriaRange(ref range, op, value, tolerance); + + private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, float value, float tolerance = 0.05f) { switch (op) { default: - return; + return false; - case "=": - case ":": + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; break; - case ">": + case Operator.Greater: range.Min = value + tolerance; break; - case ">=": - case ">:": + case Operator.GreaterOrEqual: range.Min = value - tolerance; break; - case "<": + case Operator.Less: range.Max = value - tolerance; break; - case "<=": - case "<:": + case Operator.LessOrEqual: range.Max = value + tolerance; break; } + + return true; } - private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, double value, double tolerance = 0.05) + /// + /// Attempts to parse a keyword filter of type + /// from the specified and . + /// If can be parsed as a , the function returns true + /// and the resulting range constraint is stored into . + /// + /// + /// The -typed + /// to store the parsed data into, if successful. + /// + /// The operator for the keyword filter. + /// The value of the keyword filter. + /// Allowed tolerance of the parsed range boundary value. + public static bool TryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, string val, double tolerance = 0.05) + => tryParseDoubleWithPoint(val, out double value) && tryUpdateCriteriaRange(ref range, op, value, tolerance); + + private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, double value, double tolerance = 0.05) { switch (op) { default: - return; + return false; - case "=": - case ":": + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; break; - case ">": + case Operator.Greater: range.Min = value + tolerance; break; - case ">=": - case ">:": + case Operator.GreaterOrEqual: range.Min = value - tolerance; break; - case "<": + case Operator.Less: range.Max = value - tolerance; break; - case "<=": - case "<:": + case Operator.LessOrEqual: range.Max = value + tolerance; break; } + + return true; } - private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, T value) + /// + /// Used to determine whether the string value can be converted to type . + /// If conversion can be performed, the delegate returns true + /// and the conversion result is returned in the out parameter . + /// + /// The string value to attempt parsing for. + /// The parsed value, if conversion is possible. + public delegate bool TryParseFunction(string val, out T parsed); + + /// + /// Attempts to parse a keyword filter of type , + /// from the specified and . + /// If can be parsed into using , the function returns true + /// and the resulting range constraint is stored into . + /// + /// The to store the parsed data into, if successful. + /// The operator for the keyword filter. + /// The value of the keyword filter. + /// Function used to determine if can be converted to type . + public static bool TryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, string val, TryParseFunction parseFunction) + where T : struct + => parseFunction.Invoke(val, out var converted) && tryUpdateCriteriaRange(ref range, op, converted); + + private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, T value) where T : struct { switch (op) { default: - return; + return false; - case "=": - case ":": + case Operator.Equal: range.IsLowerInclusive = range.IsUpperInclusive = true; range.Min = value; range.Max = value; break; - case ">": + case Operator.Greater: range.IsLowerInclusive = false; range.Min = value; break; - case ">=": - case ">:": + case Operator.GreaterOrEqual: range.IsLowerInclusive = true; range.Min = value; break; - case "<": + case Operator.Less: range.IsUpperInclusive = false; range.Max = value; break; - case "<=": - case "<:": + case Operator.LessOrEqual: range.IsUpperInclusive = true; range.Max = value; break; } + + return true; + } + + private static bool tryUpdateLengthRange(FilterCriteria criteria, Operator op, string val) + { + if (!tryParseDoubleWithPoint(val.TrimEnd('m', 's', 'h'), out var length)) + return false; + + var scale = getLengthScale(val); + return tryUpdateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0); } } } diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index 689a11166a..ee13ebda44 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -28,19 +28,16 @@ namespace osu.Game.Screens.Select private readonly List overlays = new List(); - /// THe button to be added. + /// The button to be added. /// The to be toggled by this button. public void AddButton(FooterButton button, OverlayContainer overlay) { - overlays.Add(overlay); - button.Action = () => showOverlay(overlay); + if (overlay != null) + { + overlays.Add(overlay); + button.Action = () => showOverlay(overlay); + } - AddButton(button); - } - - /// Button to be added. - public void AddButton(FooterButton button) - { button.Hovered = updateModeLight; button.HoverLost = updateModeLight; diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 35970cd960..cd7c1c449f 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -10,6 +10,7 @@ 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.Graphics.Sprites; using osu.Game.Graphics.Containers; @@ -21,9 +22,9 @@ namespace osu.Game.Screens.Select protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); - public string Text + public LocalisableString Text { - get => SpriteText?.Text; + get => SpriteText?.Text ?? default; set { if (SpriteText != null) diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index 6e2f3cc9df..845c0a914e 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -39,13 +40,13 @@ namespace osu.Game.Screens.Select.Options set => iconText.Icon = value; } - public string FirstLineText + public LocalisableString FirstLineText { get => firstLine.Text; set => firstLine.Text = value; } - public string SecondLineText + public LocalisableString SecondLineText { get => secondLine.Text; set => secondLine.Text = value; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 4fca77a176..b7f7c40539 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -86,10 +86,10 @@ namespace osu.Game.Screens.Select protected ModSelectOverlay ModSelect { get; private set; } - protected SampleChannel SampleConfirm { get; private set; } + protected Sample SampleConfirm { get; private set; } - private SampleChannel sampleChangeDifficulty; - private SampleChannel sampleChangeBeatmap; + private Sample sampleChangeDifficulty; + private Sample sampleChangeBeatmap; private Container carouselContainer; @@ -263,9 +263,8 @@ namespace osu.Game.Screens.Select if (Footer != null) { - Footer.AddButton(new FooterButtonMods { Current = Mods }, ModSelect); - Footer.AddButton(new FooterButtonRandom { Action = triggerRandom }); - Footer.AddButton(new FooterButtonOptions(), BeatmapOptions); + foreach (var (button, overlay) in CreateFooterButtons()) + Footer.AddButton(button, overlay); BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo)); @@ -301,7 +300,18 @@ namespace osu.Game.Screens.Select } } - protected virtual ModSelectOverlay CreateModSelectOverlay() => new ModSelectOverlay(); + /// + /// Creates the buttons to be displayed in the footer. + /// + /// A set of and an optional which the button opens when pressed. + protected virtual IEnumerable<(FooterButton, OverlayContainer)> CreateFooterButtons() => new (FooterButton, OverlayContainer)[] + { + (new FooterButtonMods { Current = Mods }, ModSelect), + (new FooterButtonRandom { Action = triggerRandom }, null), + (new FooterButtonOptions(), BeatmapOptions) + }; + + protected virtual ModSelectOverlay CreateModSelectOverlay() => new LocalPlayerModSelectOverlay(); protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { @@ -638,8 +648,9 @@ namespace osu.Game.Screens.Select { Debug.Assert(!isHandlingLooping); - music.CurrentTrack.Looping = isHandlingLooping = true; + isHandlingLooping = true; + ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); music.TrackChanged += ensureTrackLooping; } @@ -655,7 +666,7 @@ namespace osu.Game.Screens.Select } private void ensureTrackLooping(WorkingBeatmap beatmap, TrackChangeDirection changeDirection) - => music.CurrentTrack.Looping = true; + => beatmap.PrepareTrackForPreviewLooping(); public override bool OnBackButton() { @@ -709,8 +720,6 @@ namespace osu.Game.Screens.Select bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; - track.RestartPoint = Beatmap.Value.Metadata.PreviewTime; - if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) music.Play(true); diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 61d0112c89..346c7b3c65 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -24,7 +24,7 @@ namespace osu.Game.Skinning public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public override SampleChannel GetSample(ISampleInfo sampleInfo) => null; + public override Sample GetSample(ISampleInfo sampleInfo) => null; public override IBindable GetConfig(TLookup lookup) { diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index 5abd963773..ef8de01042 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -48,7 +48,7 @@ namespace osu.Game.Skinning /// The requested sample. /// A matching sample channel, or null if unavailable. [CanBeNull] - SampleChannel GetSample(ISampleInfo sampleInfo); + Sample GetSample(ISampleInfo sampleInfo); /// /// Retrieve a configuration value. diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index fdcb81b574..fb4207b647 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -39,7 +39,7 @@ namespace osu.Game.Skinning return base.GetConfig(lookup); } - public override SampleChannel GetSample(ISampleInfo sampleInfo) + public override Sample GetSample(ISampleInfo sampleInfo) { if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) { diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 0a1de461ea..5308640bdd 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -31,8 +31,6 @@ namespace osu.Game.Skinning protected override void ParseLine(List output, Section section, string line) { - line = StripComments(line); - switch (section) { case Section.Mania: diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 090ffaebd7..e5d0217671 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -29,7 +29,7 @@ namespace osu.Game.Skinning protected TextureStore Textures; [CanBeNull] - protected IResourceStore Samples; + protected ISampleStore Samples; /// /// Whether texture for the keys exists. @@ -452,7 +452,7 @@ namespace osu.Game.Skinning return null; } - public override SampleChannel GetSample(ISampleInfo sampleInfo) + public override Sample GetSample(ISampleInfo sampleInfo) { IEnumerable lookupNames; diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index ebc4757e75..e2f4a82a54 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -34,14 +34,14 @@ namespace osu.Game.Skinning public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Source.GetTexture(componentName, wrapModeS, wrapModeT); - public virtual SampleChannel GetSample(ISampleInfo sampleInfo) + public virtual Sample GetSample(ISampleInfo sampleInfo) { if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) return Source.GetSample(sampleInfo); var playLayeredHitSounds = GetConfig(LegacySetting.LayeredHitSounds); if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) - return new SampleChannelVirtual(); + return new SampleVirtual(); return Source.GetSample(sampleInfo); } diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index cb5234c847..b3c7c5d6b2 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -43,31 +43,33 @@ namespace osu.Game.Skinning if (samplePlaybackDisabler != null) { samplePlaybackDisabled.BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); - samplePlaybackDisabled.BindValueChanged(disabled => + samplePlaybackDisabled.BindValueChanged(SamplePlaybackDisabledChanged); + } + } + + protected virtual void SamplePlaybackDisabledChanged(ValueChangedEvent disabled) + { + if (!RequestedPlaying) return; + + // let non-looping samples that have already been started play out to completion (sounds better than abruptly cutting off). + if (!Looping) return; + + cancelPendingStart(); + + if (disabled.NewValue) + base.Stop(); + else + { + // schedule so we don't start playing a sample which is no longer alive. + scheduledStart = Schedule(() => { - if (!RequestedPlaying) return; - - // let non-looping samples that have already been started play out to completion (sounds better than abruptly cutting off). - if (!Looping) return; - - cancelPendingStart(); - - if (disabled.NewValue) - base.Stop(); - else - { - // schedule so we don't start playing a sample which is no longer alive. - scheduledStart = Schedule(() => - { - if (RequestedPlaying) - base.Play(); - }); - } + if (RequestedPlaying) + base.Play(); }); } } - public override void Play(bool restart = true) + public override void Play() { cancelPendingStart(); RequestedPlaying = true; @@ -75,7 +77,7 @@ namespace osu.Game.Skinning if (samplePlaybackDisabled.Value) return; - base.Play(restart); + base.Play(); } public override void Stop() diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index 2a0f480b48..9103a6a960 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -17,7 +17,7 @@ namespace osu.Game.Skinning /// /// A sample corresponding to an that supports being pooled and responding to skin changes. /// - public class PoolableSkinnableSample : SkinReloadableDrawable, IAggregateAudioAdjustment, IAdjustableAudioComponent + public class PoolableSkinnableSample : SkinReloadableDrawable, IAdjustableAudioComponent { /// /// The currently-loaded . @@ -27,6 +27,7 @@ namespace osu.Game.Skinning private readonly AudioContainer sampleContainer; private ISampleInfo sampleInfo; + private SampleChannel activeChannel; [Resolved] private ISampleStore sampleStore { get; set; } @@ -99,7 +100,7 @@ namespace osu.Game.Skinning if (ch == null) return; - sampleContainer.Add(Sample = new DrawableSample(ch) { Looping = Looping }); + sampleContainer.Add(Sample = new DrawableSample(ch)); // Start playback internally for the new sample if the previous one was playing beforehand. if (wasPlaying && Looping) @@ -109,18 +110,33 @@ namespace osu.Game.Skinning /// /// Plays the sample. /// - /// Whether to play the sample from the beginning. - public void Play(bool restart = true) => Sample?.Play(restart); + public void Play() + { + if (Sample == null) + return; + + activeChannel = Sample.GetChannel(); + activeChannel.Looping = Looping; + activeChannel.Play(); + + Played = true; + } /// /// Stops the sample. /// - public void Stop() => Sample?.Stop(); + public void Stop() + { + activeChannel?.Stop(); + activeChannel = null; + } /// /// Whether the sample is currently playing. /// - public bool Playing => Sample?.Playing ?? false; + public bool Playing => activeChannel?.Playing ?? false; + + public bool Played { get; private set; } private bool looping; @@ -134,8 +150,8 @@ namespace osu.Game.Skinning { looping = value; - if (Sample != null) - Sample.Looping = value; + if (activeChannel != null) + activeChannel.Looping = value; } } @@ -149,9 +165,13 @@ namespace osu.Game.Skinning public BindableNumber Tempo => sampleContainer.Tempo; - public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => sampleContainer.AddAdjustment(type, adjustBindable); + public void BindAdjustments(IAggregateAudioAdjustment component) => sampleContainer.BindAdjustments(component); - public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => sampleContainer.RemoveAdjustment(type, adjustBindable); + public void UnbindAdjustments(IAggregateAudioAdjustment component) => sampleContainer.UnbindAdjustments(component); + + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => sampleContainer.AddAdjustment(type, adjustBindable); + + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => sampleContainer.RemoveAdjustment(type, adjustBindable); public void RemoveAllAdjustments(AdjustableProperty type) => sampleContainer.RemoveAllAdjustments(type); diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 4b0cf02c0a..6b435cff0f 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -19,7 +19,7 @@ namespace osu.Game.Skinning public abstract Drawable GetDrawableComponent(ISkinComponent componentName); - public abstract SampleChannel GetSample(ISampleInfo sampleInfo); + public abstract Sample GetSample(ISampleInfo sampleInfo); public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); @@ -36,6 +36,7 @@ namespace osu.Game.Skinning ~Skin() { + // required to potentially clean up sample store from audio hierarchy. Dispose(false); } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 99c64b13a4..752c742a45 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -86,7 +86,7 @@ namespace osu.Game.Skinning public void SelectRandomSkin() { // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = GetAllUsableSkins().Where(s => s.ID > 0 && s.ID != CurrentSkinInfo.Value.ID).ToArray(); + var randomChoices = GetAllUsableSkins().Where(s => s.ID != CurrentSkinInfo.Value.ID).ToArray(); if (randomChoices.Length == 0) { @@ -120,7 +120,7 @@ namespace osu.Game.Skinning protected override async Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) { - await base.Populate(model, archive, cancellationToken); + await base.Populate(model, archive, cancellationToken).ConfigureAwait(false); if (model.Name?.Contains(".osk") == true) populateMetadata(model); @@ -142,6 +142,11 @@ namespace osu.Game.Skinning } } + protected override void PreImport(SkinInfo model) + { + model.Requery(ContextFactory); + } + /// /// Retrieve a instance for the provided /// @@ -171,7 +176,7 @@ namespace osu.Game.Skinning public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => CurrentSkin.Value.GetTexture(componentName, wrapModeS, wrapModeT); - public SampleChannel GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); + public Sample GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => CurrentSkin.Value.GetConfig(lookup); diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index 27cf0c697a..ba67d0a678 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -59,9 +59,9 @@ namespace osu.Game.Skinning return fallbackSource?.GetTexture(componentName, wrapModeS, wrapModeT); } - public SampleChannel GetSample(ISampleInfo sampleInfo) + public Sample GetSample(ISampleInfo sampleInfo) { - SampleChannel sourceChannel; + Sample sourceChannel; if (AllowSampleLookup(sampleInfo) && (sourceChannel = skin?.GetSample(sampleInfo)) != null) return sourceChannel; diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index a874e9a0db..57e20a8d31 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -119,13 +119,15 @@ namespace osu.Game.Skinning /// /// Plays the samples. /// - /// Whether to play the sample from the beginning. - public virtual void Play(bool restart = true) + public virtual void Play() { samplesContainer.ForEach(c => { if (PlayWhenZeroVolume || c.AggregateVolume.Value > 0) - c.Play(restart); + { + c.Stop(); + c.Play(); + } }); } @@ -174,10 +176,20 @@ namespace osu.Game.Skinning public BindableNumber Tempo => samplesContainer.Tempo; - public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) + public void BindAdjustments(IAggregateAudioAdjustment component) + { + samplesContainer.BindAdjustments(component); + } + + public void UnbindAdjustments(IAggregateAudioAdjustment component) + { + samplesContainer.UnbindAdjustments(component); + } + + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => samplesContainer.AddAdjustment(type, adjustBindable); - public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => samplesContainer.RemoveAdjustment(type, adjustBindable); public void RemoveAllAdjustments(AdjustableProperty type) @@ -188,6 +200,16 @@ namespace osu.Game.Skinning /// public bool IsPlaying => samplesContainer.Any(s => s.Playing); + public bool IsPlayed => samplesContainer.Any(s => s.Played); + + public IBindable AggregateVolume => samplesContainer.AggregateVolume; + + public IBindable AggregateBalance => samplesContainer.AggregateBalance; + + public IBindable AggregateFrequency => samplesContainer.AggregateFrequency; + + public IBindable AggregateTempo => samplesContainer.AggregateTempo; + #endregion } } diff --git a/osu.Game/Skinning/SkinnableSpriteText.cs b/osu.Game/Skinning/SkinnableSpriteText.cs index 567dd348e1..06461127b1 100644 --- a/osu.Game/Skinning/SkinnableSpriteText.cs +++ b/osu.Game/Skinning/SkinnableSpriteText.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Skinning { @@ -21,9 +22,9 @@ namespace osu.Game.Skinning textDrawable.Text = Text; } - private string text; + private LocalisableString text; - public string Text + public LocalisableString Text { get => text; set diff --git a/osu.Game/Storyboards/CommandTimelineGroup.cs b/osu.Game/Storyboards/CommandTimelineGroup.cs index 6ce3b617e9..c478b91c22 100644 --- a/osu.Game/Storyboards/CommandTimelineGroup.cs +++ b/osu.Game/Storyboards/CommandTimelineGroup.cs @@ -45,11 +45,30 @@ namespace osu.Game.Storyboards }; } + /// + /// Returns the earliest visible time. Will be null unless this group's first command has a start value of zero. + /// + public double? EarliestDisplayedTime + { + get + { + var first = Alpha.Commands.FirstOrDefault(); + + return first?.StartValue == 0 ? first.StartTime : (double?)null; + } + } + [JsonIgnore] public double CommandsStartTime { get { + // if the first alpha command starts at zero it should be given priority over anything else. + // this is due to it creating a state where the target is not present before that time, causing any other events to not be visible. + var earliestDisplay = EarliestDisplayedTime; + if (earliestDisplay != null) + return earliestDisplay.Value; + double min = double.MaxValue; for (int i = 0; i < timelines.Length; i++) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 7eac994e07..81623a9307 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; @@ -80,17 +81,17 @@ namespace osu.Game.Storyboards.Drawables if (FlipH) { - if (origin.HasFlag(Anchor.x0)) + if (origin.HasFlagFast(Anchor.x0)) origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlag(Anchor.x2)) + else if (origin.HasFlagFast(Anchor.x2)) origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); } if (FlipV) { - if (origin.HasFlag(Anchor.y0)) + if (origin.HasFlagFast(Anchor.y0)) origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlag(Anchor.y2)) + else if (origin.HasFlagFast(Anchor.y2)) origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index 218f051bf0..7b16009859 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -42,6 +42,21 @@ namespace osu.Game.Storyboards.Drawables } } + protected override void SamplePlaybackDisabledChanged(ValueChangedEvent disabled) + { + if (!RequestedPlaying) return; + + if (!Looping && disabled.NewValue) + { + // the default behaviour for sample disabling is to allow one-shot samples to play out. + // storyboards regularly have long running samples that can cause this behaviour to lead to unintended results. + // for this reason, we immediately stop such samples. + Stop(); + } + + base.SamplePlaybackDisabledChanged(disabled); + } + protected override void Update() { base.Update(); diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index 7b1a6d54da..eb877f3dff 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; @@ -80,17 +81,17 @@ namespace osu.Game.Storyboards.Drawables if (FlipH) { - if (origin.HasFlag(Anchor.x0)) + if (origin.HasFlagFast(Anchor.x0)) origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlag(Anchor.x2)) + else if (origin.HasFlagFast(Anchor.x2)) origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); } if (FlipV) { - if (origin.HasFlag(Anchor.y0)) + if (origin.HasFlagFast(Anchor.y0)) origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlag(Anchor.y2)) + else if (origin.HasFlagFast(Anchor.y2)) origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index f411ad04f3..fdaa59d7d9 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -24,13 +24,46 @@ namespace osu.Game.Storyboards public readonly CommandTimelineGroup TimelineGroup = new CommandTimelineGroup(); - public double StartTime => Math.Min( - TimelineGroup.HasCommands ? TimelineGroup.CommandsStartTime : double.MaxValue, - loops.Any(l => l.HasCommands) ? loops.Where(l => l.HasCommands).Min(l => l.StartTime) : double.MaxValue); + public double StartTime + { + get + { + // check for presence affecting commands as an initial pass. + double earliestStartTime = TimelineGroup.EarliestDisplayedTime ?? double.MaxValue; - public double EndTime => Math.Max( - TimelineGroup.HasCommands ? TimelineGroup.CommandsEndTime : double.MinValue, - loops.Any(l => l.HasCommands) ? loops.Where(l => l.HasCommands).Max(l => l.EndTime) : double.MinValue); + foreach (var l in loops) + { + if (!(l.EarliestDisplayedTime is double lEarliest)) + continue; + + earliestStartTime = Math.Min(earliestStartTime, lEarliest); + } + + if (earliestStartTime < double.MaxValue) + return earliestStartTime; + + // if an alpha-affecting command was not found, use the earliest of any command. + earliestStartTime = TimelineGroup.StartTime; + + foreach (var l in loops) + earliestStartTime = Math.Min(earliestStartTime, l.StartTime); + + return earliestStartTime; + } + } + + public double EndTime + { + get + { + double latestEndTime = TimelineGroup.EndTime; + + foreach (var l in loops) + latestEndTime = Math.Max(latestEndTime, l.EndTime); + + return latestEndTime; + } + } public bool HasCommands => TimelineGroup.HasCommands || loops.Any(l => l.HasCommands); diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index a87b22affe..7775c2bd24 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -7,8 +7,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Multiplayer { @@ -48,7 +50,24 @@ namespace osu.Game.Tests.Visual.Multiplayer RoomManager.Schedule(() => RoomManager.PartRoom()); if (joinRoom) + { + Room.Name.Value = "test name"; + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + }); + RoomManager.Schedule(() => RoomManager.CreateRoom(Room)); + } }); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + if (joinRoom) + AddUntilStep("wait for room join", () => Client.Room != null); + } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 7fbc770351..09fcc1ff47 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -3,14 +3,19 @@ #nullable enable +using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer @@ -23,6 +28,16 @@ namespace osu.Game.Tests.Visual.Multiplayer [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private readonly TestMultiplayerRoomManager roomManager; + + public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) + { + this.roomManager = roomManager; + } + public void Connect() => isConnected.Value = true; public void Disconnect() => isConnected.Value = false; @@ -87,24 +102,41 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override Task JoinRoom(long roomId) { - var user = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value }; + var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == roomId); - var room = new MultiplayerRoom(roomId); - room.Users.Add(user); + var user = new MultiplayerRoomUser(api.LocalUser.Value.Id) + { + User = api.LocalUser.Value + }; - if (room.Users.Count == 1) - room.Host = user; + var room = new MultiplayerRoom(roomId) + { + Settings = + { + Name = apiRoom.Name.Value, + BeatmapID = apiRoom.Playlist.Last().BeatmapID, + RulesetID = apiRoom.Playlist.Last().RulesetID, + BeatmapChecksum = apiRoom.Playlist.Last().Beatmap.Value.MD5Hash, + RequiredMods = apiRoom.Playlist.Last().RequiredMods.Select(m => new APIMod(m)).ToArray(), + AllowedMods = apiRoom.Playlist.Last().AllowedMods.Select(m => new APIMod(m)).ToArray(), + PlaylistItemId = apiRoom.Playlist.Last().ID + }, + Users = { user }, + Host = user + }; return Task.FromResult(room); } + protected override Task LeaveRoomInternal() => Task.CompletedTask; + public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); public override async Task ChangeSettings(MultiplayerRoomSettings settings) { Debug.Assert(Room != null); - await ((IMultiplayerClient)this).SettingsChanged(settings); + await ((IMultiplayerClient)this).SettingsChanged(settings).ConfigureAwait(false); foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.Idle); @@ -122,6 +154,21 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public void ChangeUserMods(int userId, IEnumerable newMods) + => ChangeUserMods(userId, newMods.Select(m => new APIMod(m)).ToList()); + + public void ChangeUserMods(int userId, IEnumerable newMods) + { + Debug.Assert(Room != null); + ((IMultiplayerClient)this).UserModsChanged(userId, newMods.ToList()); + } + + public override Task ChangeUserMods(IEnumerable newMods) + { + ChangeUserMods(api.LocalUser.Value.Id, newMods); + return Task.CompletedTask; + } + public override Task StartMatch() { Debug.Assert(Room != null); @@ -131,5 +178,19 @@ namespace osu.Game.Tests.Visual.Multiplayer return ((IMultiplayerClient)this).LoadRequested(); } + + protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + { + Debug.Assert(Room != null); + + var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == Room.RoomID); + var set = apiRoom.Playlist.FirstOrDefault(p => p.BeatmapID == beatmapId)?.Beatmap.Value.BeatmapSet + ?? beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId)?.BeatmapSet; + + if (set == null) + throw new InvalidOperationException("Beatmap not found."); + + return Task.FromResult(set); + } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs index 860caef071..e57411d04d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs @@ -32,11 +32,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { RelativeSizeAxes = Axes.Both; + RoomManager = new TestMultiplayerRoomManager(); + Client = new TestMultiplayerClient(RoomManager); + OngoingOperationTracker = new OngoingOperationTracker(); + AddRangeInternal(new Drawable[] { - Client = new TestMultiplayerClient(), - RoomManager = new TestMultiplayerRoomManager(), - OngoingOperationTracker = new OngoingOperationTracker(), + Client, + RoomManager, + OngoingOperationTracker, content = new Container { RelativeSizeAxes = Axes.Both } }); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index 5e12156f3c..7e824c4d7c 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Cached] public readonly Bindable Filter = new Bindable(new FilterCriteria()); - private readonly List rooms = new List(); + public new readonly List Rooms = new List(); protected override void LoadComplete() { @@ -35,6 +35,7 @@ namespace osu.Game.Tests.Visual.Multiplayer int currentScoreId = 0; int currentRoomId = 0; + int currentPlaylistItemId = 0; ((DummyAPIAccess)api).HandleRequest = req => { @@ -46,7 +47,10 @@ namespace osu.Game.Tests.Visual.Multiplayer createdRoom.CopyFrom(createRoomRequest.Room); createdRoom.RoomID.Value ??= currentRoomId++; - rooms.Add(createdRoom); + for (int i = 0; i < createdRoom.Playlist.Count; i++) + createdRoom.Playlist[i].ID = currentPlaylistItemId++; + + Rooms.Add(createdRoom); createRoomRequest.TriggerSuccess(createdRoom); break; @@ -61,7 +65,7 @@ namespace osu.Game.Tests.Visual.Multiplayer case GetRoomsRequest getRoomsRequest: var roomsWithoutParticipants = new List(); - foreach (var r in rooms) + foreach (var r in Rooms) { var newRoom = new Room(); @@ -75,7 +79,7 @@ namespace osu.Game.Tests.Visual.Multiplayer break; case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); + getRoomRequest.TriggerSuccess(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); break; case GetBeatmapSetRequest getBeatmapSetRequest: diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index f47391ce6a..0addc9de75 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -34,6 +34,8 @@ namespace osu.Game.Tests.Visual public new HealthProcessor HealthProcessor => base.HealthProcessor; + public new bool PauseCooldownActive => base.PauseCooldownActive; + public readonly List Results = new List(); public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index 4ebf2a7368..50572a7867 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -37,7 +37,7 @@ namespace osu.Game.Updater { var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); - await releases.PerformAsync(); + await releases.PerformAsync().ConfigureAwait(false); var latest = releases.ResponseObject; @@ -77,7 +77,7 @@ namespace osu.Game.Updater bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe", StringComparison.Ordinal)); break; - case RuntimeInfo.Platform.MacOsx: + case RuntimeInfo.Platform.macOS: bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip", StringComparison.Ordinal)); break; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index f772c6d282..1c72f3ebe2 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -52,7 +52,7 @@ namespace osu.Game.Updater // debug / local compilations will reset to a non-release string. // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). - config.Set(OsuSetting.Version, version); + config.SetValue(OsuSetting.Version, version); } private readonly object updateTaskLock = new object(); @@ -69,7 +69,7 @@ namespace osu.Game.Updater lock (updateTaskLock) waitTask = (updateCheckTask ??= PerformUpdateCheck()); - bool hasUpdates = await waitTask; + bool hasUpdates = await waitTask.ConfigureAwait(false); lock (updateTaskLock) updateCheckTask = null; diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index d7e78d5b35..6c45417db0 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Game.Online.API.Requests; namespace osu.Game.Users { @@ -69,9 +72,6 @@ namespace osu.Game.Users [JsonProperty(@"support_level")] public int SupportLevel; - [JsonProperty(@"current_mode_rank")] - public int? CurrentModeRank; - [JsonProperty(@"is_gmt")] public bool IsGMT; @@ -111,9 +111,6 @@ namespace osu.Game.Users [JsonProperty(@"twitter")] public string Twitter; - [JsonProperty(@"skype")] - public string Skype; - [JsonProperty(@"discord")] public string Discord; @@ -126,6 +123,9 @@ namespace osu.Game.Users [JsonProperty(@"follower_count")] public int FollowerCount; + [JsonProperty(@"mapping_follower_count")] + public int MappingFollowerCount; + [JsonProperty(@"favourite_beatmapset_count")] public int FavouriteBeatmapsetCount; @@ -175,6 +175,10 @@ namespace osu.Game.Users private UserStatistics statistics; + /// + /// User statistics for the requested ruleset (in the case of a or response). + /// Otherwise empty. + /// [JsonProperty(@"statistics")] public UserStatistics Statistics { @@ -225,14 +229,14 @@ namespace osu.Game.Users [JsonProperty("replays_watched_counts")] public UserHistoryCount[] ReplaysWatchedCounts; - public class UserHistoryCount - { - [JsonProperty("start_date")] - public DateTime Date; - - [JsonProperty("count")] - public long Count; - } + /// + /// All user statistics per ruleset's short name (in the case of a response). + /// Otherwise empty. Can be altered for testing purposes. + /// + // todo: this should likely be moved to a separate UserCompact class at some point. + [JsonProperty("statistics_rulesets")] + [CanBeNull] + public Dictionary RulesetsStatistics { get; set; } public override string ToString() => Username; @@ -246,6 +250,14 @@ namespace osu.Game.Users Id = 0 }; + public bool Equals(User other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Id == other.Id; + } + public enum PlayStyle { [Description("Keyboard")] @@ -261,12 +273,13 @@ namespace osu.Game.Users Touch, } - public bool Equals(User other) + public class UserHistoryCount { - if (ReferenceEquals(null, other)) return false; - if (ReferenceEquals(this, other)) return true; + [JsonProperty("start_date")] + public DateTime Date; - return Id == other.Id; + [JsonProperty("count")] + public long Count; } } } diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 8b7699d0ad..dc926898fc 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -26,18 +26,18 @@ namespace osu.Game.Users public int Progress; } + [JsonProperty(@"global_rank")] + public int? GlobalRank; + + [JsonProperty(@"country_rank")] + public int? CountryRank; + + // populated via User model, as that's where the data currently lives. + public RankHistoryData RankHistory; + [JsonProperty(@"pp")] public decimal? PP; - [JsonProperty(@"pp_rank")] // the API sometimes only returns this value in condensed user responses - private int? rank - { - set => Ranks.Global = value; - } - - [JsonProperty(@"rank")] - public UserRanks Ranks; - [JsonProperty(@"ranked_score")] public long RankedScore; @@ -112,16 +112,5 @@ namespace osu.Game.Users } } } - - public struct UserRanks - { - [JsonProperty(@"global")] - public int? Global; - - [JsonProperty(@"country")] - public int? Country; - } - - public RankHistoryData RankHistory; } } diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs new file mode 100644 index 0000000000..c12b5a9fd4 --- /dev/null +++ b/osu.Game/Utils/ModUtils.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Game.Rulesets.Mods; + +#nullable enable + +namespace osu.Game.Utils +{ + /// + /// A set of utilities to handle combinations. + /// + public static class ModUtils + { + /// + /// Checks that all s are compatible with each-other, and that all appear within a set of allowed types. + /// + /// + /// The allowed types must contain exact types for the respective s to be allowed. + /// + /// The s to check. + /// The set of allowed types. + /// Whether all s are compatible with each-other and appear in the set of allowed types. + public static bool CheckCompatibleSetAndAllowed(IEnumerable combination, IEnumerable allowedTypes) + { + // Prevent multiple-enumeration. + var combinationList = combination as ICollection ?? combination.ToArray(); + return CheckCompatibleSet(combinationList, out _) && CheckAllowed(combinationList, allowedTypes); + } + + /// + /// Checks that all s in a combination are compatible with each-other. + /// + /// The combination to check. + /// Whether all s in the combination are compatible with each-other. + public static bool CheckCompatibleSet(IEnumerable combination) + => CheckCompatibleSet(combination, out _); + + /// + /// Checks that all s in a combination are compatible with each-other. + /// + /// The combination to check. + /// Any invalid mods in the set. + /// Whether all s in the combination are compatible with each-other. + public static bool CheckCompatibleSet(IEnumerable combination, [NotNullWhen(false)] out List? invalidMods) + { + combination = FlattenMods(combination).ToArray(); + invalidMods = null; + + foreach (var mod in combination) + { + foreach (var type in mod.IncompatibleMods) + { + foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m))) + { + invalidMods ??= new List(); + invalidMods.Add(invalid); + } + } + } + + return invalidMods == null; + } + + /// + /// Checks that all s in a combination appear within a set of allowed types. + /// + /// + /// The set of allowed types must contain exact types for the respective s to be allowed. + /// + /// The combination to check. + /// The set of allowed types. + /// Whether all s in the combination are allowed. + public static bool CheckAllowed(IEnumerable combination, IEnumerable allowedTypes) + { + var allowedSet = new HashSet(allowedTypes); + + return combination.SelectMany(FlattenMod) + .All(m => allowedSet.Contains(m.GetType())); + } + + /// + /// Check the provided combination of mods are valid for a local gameplay session. + /// + /// The mods to check. + /// Invalid mods, if any were found. Can be null if all mods were valid. + /// Whether the input mods were all valid. If false, will contain all invalid entries. + public static bool CheckValidForGameplay(IEnumerable mods, out List? invalidMods) + { + mods = mods.ToArray(); + + CheckCompatibleSet(mods, out invalidMods); + + foreach (var mod in mods) + { + if (mod.Type == ModType.System || !mod.HasImplementation || mod is MultiMod) + { + invalidMods ??= new List(); + invalidMods.Add(mod); + } + } + + return invalidMods == null; + } + + /// + /// Flattens a set of s, returning a new set with all s removed. + /// + /// The set of s to flatten. + /// The new set, containing all s in recursively with all s removed. + public static IEnumerable FlattenMods(IEnumerable mods) => mods.SelectMany(FlattenMod); + + /// + /// Flattens a , returning a set of s in-place of any s. + /// + /// The to flatten. + /// A set of singular "flattened" s + public static IEnumerable FlattenMod(Mod mod) + { + if (mod is MultiMod multi) + { + foreach (var m in multi.Mods.SelectMany(FlattenMod)) + yield return m; + } + else + yield return mod; + } + } +} diff --git a/osu.Game/Utils/OrderAttribute.cs b/osu.Game/Utils/OrderAttribute.cs deleted file mode 100644 index aded7f9814..0000000000 --- a/osu.Game/Utils/OrderAttribute.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace osu.Game.Utils -{ - public static class OrderAttributeUtils - { - /// - /// Get values of an enum in order. Supports custom ordering via . - /// - public static IEnumerable GetValuesInOrder() - { - var type = typeof(T); - - if (!type.IsEnum) - throw new InvalidOperationException("T must be an enum"); - - IEnumerable items = (T[])Enum.GetValues(type); - - if (Attribute.GetCustomAttribute(type, typeof(HasOrderedElementsAttribute)) == null) - return items; - - return items.OrderBy(i => - { - if (type.GetField(i.ToString()).GetCustomAttributes(typeof(OrderAttribute), false).FirstOrDefault() is OrderAttribute attr) - return attr.Order; - - throw new ArgumentException($"Not all values of {nameof(T)} have {nameof(OrderAttribute)} specified."); - }); - } - } - - [AttributeUsage(AttributeTargets.Field)] - public class OrderAttribute : Attribute - { - public readonly int Order; - - public OrderAttribute(int order) - { - Order = order; - } - } - - [AttributeUsage(AttributeTargets.Enum)] - public class HasOrderedElementsAttribute : Attribute - { - } -} diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index e8e41cdbbe..8f12760a6b 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -23,7 +23,7 @@ namespace osu.Game.Utils var options = new SentryOptions { - Dsn = new Dsn("https://5e342cd55f294edebdc9ad604d28bbd3@sentry.io/1255255"), + Dsn = "https://5e342cd55f294edebdc9ad604d28bbd3@sentry.io/1255255", Release = game.Version }; @@ -86,11 +86,6 @@ namespace osu.Game.Utils #region Disposal - ~SentryLogger() - { - Dispose(false); - } - public void Dispose() { Dispose(true); diff --git a/osu.Game/Utils/TaskChain.cs b/osu.Game/Utils/TaskChain.cs new file mode 100644 index 0000000000..df28faf9fb --- /dev/null +++ b/osu.Game/Utils/TaskChain.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. + +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Extensions; + +namespace osu.Game.Utils +{ + /// + /// A chain of s that run sequentially. + /// + public class TaskChain + { + private readonly object taskLock = new object(); + + private Task lastTaskInChain = Task.CompletedTask; + + /// + /// Adds a new task to the end of this . + /// + /// The action to be executed. + /// The for this task. Does not affect further tasks in the chain. + /// The awaitable . + public Task Add(Action action, CancellationToken cancellationToken = default) + { + lock (taskLock) + return lastTaskInChain = lastTaskInChain.ContinueWithSequential(action, cancellationToken); + } + + /// + /// Adds a new task to the end of this . + /// + /// The task to be executed. + /// The for this task. Does not affect further tasks in the chain. + /// The awaitable . + public Task Add(Func task, CancellationToken cancellationToken = default) + { + lock (taskLock) + return lastTaskInChain = lastTaskInChain.ContinueWithSequential(task, cancellationToken); + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 7c49a250c4..daf05adc5f 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -22,17 +22,21 @@ - - - - + + + + + + + + - - - - - - + + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index 4732620085..b763a91dfb 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,29 +70,32 @@ - - + + $(NoWarn);NU1605 + - - + + none + + + none + - - - - + + - +