diff --git a/Directory.Build.props b/Directory.Build.props index 5bdf12218c..709545bf1d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -26,14 +26,6 @@ true $(NoWarn);CS1591 - - - $(NoWarn);NU1701 - false ppy Pty Ltd @@ -42,7 +34,7 @@ https://github.com/ppy/osu Automated release. ppy Pty Ltd - Copyright (c) 2021 ppy Pty Ltd + Copyright (c) 2022 ppy Pty Ltd osu game diff --git a/LICENCE b/LICENCE index b5962ad3b2..d3e7537cef 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -Copyright (c) 2021 ppy Pty Ltd . +Copyright (c) 2022 ppy Pty Ltd . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Templates/osu.Game.Templates.csproj b/Templates/osu.Game.Templates.csproj index 4624d3d771..b8c3ad373a 100644 --- a/Templates/osu.Game.Templates.csproj +++ b/Templates/osu.Game.Templates.csproj @@ -8,7 +8,7 @@ https://github.com/ppy/osu/blob/master/Templates https://github.com/ppy/osu Automated release. - Copyright (c) 2021 ppy Pty Ltd + Copyright (c) 2022 ppy Pty Ltd Templates to use when creating a ruleset for consumption in osu!. dotnet-new;templates;osu netstandard2.1 diff --git a/osu.Android.props b/osu.Android.props index fbe13b11ee..331c4db01f 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index a06484214b..3ca6411812 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -25,7 +25,6 @@ - @@ -33,7 +32,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index 1757fd7c73..dc1ec17e2c 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -11,7 +11,7 @@ false A free-to-win rhythm game. Rhythm is just a *click* away! testing - Copyright (c) 2021 ppy Pty Ltd + Copyright (c) 2022 ppy Pty Ltd en-AU diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index ad4c5dfd5d..7567c96b50 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.DifficultyIncrease; public override double ScoreMultiplier => 1.12; + public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight) }; + private DrawableOsuBlinds blinds; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 38c84be295..44d72fae61 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.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.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; @@ -19,6 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject { public override double ScoreMultiplier => 1.12; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray(); private const double default_follow_delay = 120; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs index 778447e444..70c075276f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods protected virtual float EndScale => 1; - public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn) }; + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModObjectScaleTween) }; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 628d95dff4..a1c8879105 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public readonly IBindable ScaleBindable = new BindableFloat(); public readonly IBindable IndexInCurrentComboBindable = new Bindable(); - // Must be set to update IsHovered as it's used in relax mdo to detect osu hit objects. + // Must be set to update IsHovered as it's used in relax mod to detect osu hit objects. public override bool HandlePositionalInput => true; protected override float SamplePlaybackPosition => HitObject.X / OsuPlayfield.BASE_SIZE.X; diff --git a/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs b/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs new file mode 100644 index 0000000000..312b939315 --- /dev/null +++ b/osu.Game.Tests/Mods/MultiModIncompatibilityTest.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; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Game.Utils; + +namespace osu.Game.Tests.Mods +{ + [TestFixture] + public class MultiModIncompatibilityTest + { + /// + /// Ensures that all mods grouped into s, as declared by the default rulesets, are pairwise incompatible with each other. + /// + [TestCase(typeof(OsuRuleset))] + [TestCase(typeof(TaikoRuleset))] + [TestCase(typeof(CatchRuleset))] + [TestCase(typeof(ManiaRuleset))] + public void TestAllMultiModsFromRulesetAreIncompatible(Type rulesetType) + { + var ruleset = (Ruleset)Activator.CreateInstance(rulesetType); + Assert.That(ruleset, Is.Not.Null); + + var allMultiMods = getMultiMods(ruleset); + + Assert.Multiple(() => + { + foreach (var multiMod in allMultiMods) + { + int modCount = multiMod.Mods.Length; + + for (int i = 0; i < modCount; ++i) + { + // indexing from i + 1 ensures that only pairs of different mods are checked, and are checked only once + // (indexing from 0 would check each pair twice, and also check each mod against itself). + for (int j = i + 1; j < modCount; ++j) + { + var firstMod = multiMod.Mods[i]; + var secondMod = multiMod.Mods[j]; + + Assert.That( + ModUtils.CheckCompatibleSet(new[] { firstMod, secondMod }), Is.False, + $"{firstMod.Name} ({firstMod.Acronym}) and {secondMod.Name} ({secondMod.Acronym}) should be incompatible."); + } + } + } + }); + } + + /// + /// This local helper is used rather than , because the aforementioned method flattens multi mods. + /// > + private static IEnumerable getMultiMods(Ruleset ruleset) + => Enum.GetValues(typeof(ModType)).Cast().SelectMany(ruleset.GetModsFor).OfType(); + } +} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs index 7516e7500b..76c49edf78 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs @@ -69,6 +69,34 @@ namespace osu.Game.Tests.NonVisual.Skinning "Gameplay/osu/followpoint", "followpoint", 1 }, + new object[] + { + // Looking up a filename with extension specified should work. + new[] { "followpoint.png" }, + "followpoint.png", + "followpoint.png", 1 + }, + new object[] + { + // Looking up a filename with extension specified should also work with @2x sprites. + new[] { "followpoint@2x.png" }, + "followpoint.png", + "followpoint@2x.png", 2 + }, + new object[] + { + // Looking up a path with extension specified should work. + new[] { "Gameplay/osu/followpoint.png" }, + "Gameplay/osu/followpoint.png", + "Gameplay/osu/followpoint.png", 1 + }, + new object[] + { + // Looking up a path with extension specified should also work with @2x sprites. + new[] { "Gameplay/osu/followpoint@2x.png" }, + "Gameplay/osu/followpoint.png", + "Gameplay/osu/followpoint@2x.png", 2 + }, }; [TestCaseSource(nameof(fallbackTestCases))] diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 9b0facd625..dde8715764 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Skinning; @@ -110,6 +111,27 @@ namespace osu.Game.Tests.Skins.IO assertImportedOnce(import1, import2); }); + [Test] + public Task TestImportExportedSkinFilename() => runSkinTest(async osu => + { + MemoryStream exportStream = new MemoryStream(); + + var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "custom.osk")); + assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu); + + import1.PerformRead(s => + { + new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + }); + + string exportFilename = import1.GetDisplayString(); + + var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(exportStream, $"{exportFilename}.osk")); + assertCorrectMetadata(import2, "name 1 [custom]", "author 1", osu); + + assertImportedOnce(import1, import2); + }); + [Test] public Task TestSameMetadataNameSameFolderName() => runSkinTest(async osu => { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index 34e6d1996d..b2f4fa2738 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -7,11 +7,10 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; using osuTK; @@ -36,7 +35,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); - assertSpritesFromSkin(false); + AddAssert("sprite didn't find texture", () => + sprites.All(sprite => sprite.ChildrenOfType().All(s => s.Texture == null))); } [Test] @@ -48,9 +48,12 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); - assertSpritesFromSkin(true); + // Only checking for at least one sprite that succeeded, as not all skins in this test provide the hitcircleoverlay texture. + AddAssert("sprite found texture", () => + sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Texture != null))); - AddAssert("skinnable sprite has correct size", () => sprites.Any(s => Precision.AlmostEquals(s.ChildrenOfType().Single().Size, new Vector2(128, 128)))); + AddAssert("skinnable sprite has correct size", () => + sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Size == new Vector2(128)))); } [Test] @@ -104,9 +107,5 @@ namespace osu.Game.Tests.Visual.Gameplay s.LifetimeStart = double.MinValue; s.LifetimeEnd = double.MaxValue; }); - - private void assertSpritesFromSkin(bool fromSkin) => - AddAssert($"sprites are {(fromSkin ? "from skin" : "from storyboard")}", - () => sprites.All(sprite => sprite.ChildrenOfType().Any() == fromSkin)); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index 8b7e1c4e58..5d25287e45 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -150,10 +150,12 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("import score", () => imported = scoreManager.Import(getScoreInfo(true))); AddUntilStep("state is available", () => downloadButton.State.Value == DownloadState.LocallyAvailable); + AddAssert("button is enabled", () => downloadButton.ChildrenOfType().First().Enabled.Value); AddStep("delete score", () => scoreManager.Delete(imported.Value)); AddUntilStep("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs new file mode 100644 index 0000000000..98e6cd514b --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -0,0 +1,204 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public abstract class MultiplayerGameplayLeaderboardTestScene : OsuTestScene + { + protected readonly BindableList MultiplayerUsers = new BindableList(); + + protected MultiplayerGameplayLeaderboard Leaderboard { get; private set; } + + protected virtual MultiplayerRoomUser CreateUser(int userId) => new MultiplayerRoomUser(userId); + + protected abstract MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor); + + private readonly BindableList multiplayerUserIds = new BindableList(); + + private OsuConfigManager config; + + private readonly Mock spectatorClient = new Mock(); + private readonly Mock multiplayerClient = new Mock(); + + private readonly Dictionary lastHeaders = new Dictionary(); + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); + Dependencies.CacheAs(spectatorClient.Object); + Dependencies.CacheAs(multiplayerClient.Object); + + // To emulate `MultiplayerClient.CurrentMatchPlayingUserIds` we need a bindable list of *only IDs*. + // This tracks the list of users 1:1. + MultiplayerUsers.BindCollectionChanged((c, e) => + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + + foreach (var user in e.NewItems.OfType()) + multiplayerUserIds.Add(user.UserID); + break; + + case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + + foreach (var user in e.OldItems.OfType()) + multiplayerUserIds.Remove(user.UserID); + break; + + case NotifyCollectionChangedAction.Reset: + multiplayerUserIds.Clear(); + break; + } + }); + + multiplayerClient.SetupGet(c => c.CurrentMatchPlayingUserIds) + .Returns(() => multiplayerUserIds); + } + + [SetUpSteps] + public virtual void SetUpSteps() + { + AddStep("reset counts", () => spectatorClient.Invocations.Clear()); + + AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = new APIUser + { + Id = 1, + }); + + AddStep("populate users", () => + { + MultiplayerUsers.Clear(); + for (int i = 0; i < 16; i++) + MultiplayerUsers.Add(CreateUser(i)); + }); + + AddStep("create leaderboard", () => + { + Leaderboard?.Expire(); + + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); + var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + OsuScoreProcessor scoreProcessor = new OsuScoreProcessor(); + scoreProcessor.ApplyBeatmap(playableBeatmap); + + Child = scoreProcessor; + + LoadComponentAsync(Leaderboard = CreateLeaderboard(scoreProcessor), Add); + }); + + AddUntilStep("wait for load", () => Leaderboard.IsLoaded); + + AddStep("check watch requests were sent", () => + { + foreach (var user in MultiplayerUsers) + spectatorClient.Verify(s => s.WatchUser(user.UserID), Times.Once); + }); + } + + [Test] + public void TestScoreUpdates() + { + AddRepeatStep("update state", UpdateUserStatesRandomly, 100); + AddToggleStep("switch compact mode", expanded => Leaderboard.Expanded.Value = expanded); + } + + [Test] + public void TestUserQuit() + { + AddUntilStep("mark users quit", () => + { + if (MultiplayerUsers.Count == 0) + return true; + + MultiplayerUsers.RemoveAt(0); + return false; + }); + + AddStep("check stop watching requests were sent", () => + { + foreach (var user in MultiplayerUsers) + spectatorClient.Verify(s => s.StopWatchingUser(user.UserID), Times.Once); + }); + } + + [Test] + public void TestChangeScoringMode() + { + AddRepeatStep("update state", UpdateUserStatesRandomly, 5); + AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic)); + AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); + } + + protected void UpdateUserStatesRandomly() + { + foreach (var user in MultiplayerUsers) + { + if (RNG.NextBool()) + continue; + + int userId = user.UserID; + + if (!lastHeaders.TryGetValue(userId, out var header)) + { + lastHeaders[userId] = header = new FrameHeader(new ScoreInfo + { + Statistics = new Dictionary + { + [HitResult.Miss] = 0, + [HitResult.Meh] = 0, + [HitResult.Great] = 0 + } + }); + } + + switch (RNG.Next(0, 3)) + { + case 0: + header.Combo = 0; + header.Statistics[HitResult.Miss]++; + break; + + case 1: + header.Combo++; + header.MaxCombo = Math.Max(header.MaxCombo, header.Combo); + header.Statistics[HitResult.Meh]++; + break; + + default: + header.Combo++; + header.MaxCombo = Math.Max(header.MaxCombo, header.Combo); + header.Statistics[HitResult.Great]++; + break; + } + + spectatorClient.Raise(s => s.OnNewFrames -= null, userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) })); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index d8ec0ad1f0..7d010592ae 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -34,9 +35,11 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestMultipleStatuses() { + FillFlowContainer rooms = null; + AddStep("create rooms", () => { - Child = new FillFlowContainer + Child = rooms = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -124,6 +127,10 @@ namespace osu.Game.Tests.Visual.Multiplayer } }; }); + + AddUntilStep("wait for panel load", () => rooms.Count == 5); + AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Currently playing", StringComparison.Ordinal)) == 2); + AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Ready to play", StringComparison.Ordinal)) == 3); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs index 512d206a06..c3487751b9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs @@ -9,13 +9,14 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneGameplayChatDisplay : MultiplayerTestScene + public class TestSceneGameplayChatDisplay : OsuManualInputManagerTestScene { private GameplayChatDisplay chatDisplay; @@ -35,11 +36,9 @@ namespace osu.Game.Tests.Visual.Multiplayer } [SetUpSteps] - public override void SetUpSteps() + public void SetUpSteps() { - base.SetUpSteps(); - - AddStep("load chat display", () => Child = chatDisplay = new GameplayChatDisplay(SelectedRoom.Value) + AddStep("load chat display", () => Child = chatDisplay = new GameplayChatDisplay(new Room()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index bcd4474876..6e4aa48b0e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -1,161 +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; -using System.Collections.Generic; using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Utils; -using osu.Game.Configuration; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Spectator; -using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; -using osu.Game.Tests.Visual.OnlinePlay; -using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerTestScene + public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerGameplayLeaderboardTestScene { - private static IEnumerable users => Enumerable.Range(0, 16); - - public new TestMultiplayerSpectatorClient SpectatorClient => (TestMultiplayerSpectatorClient)OnlinePlayDependencies?.SpectatorClient; - - private MultiplayerGameplayLeaderboard leaderboard; - private OsuConfigManager config; - - [BackgroundDependencyLoader] - private void load() + protected override MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor) { - Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); - } - - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = UserLookupCache.GetUserAsync(1).GetResultSafely()); - - AddStep("create leaderboard", () => + return new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, MultiplayerUsers.ToArray()) { - leaderboard?.Expire(); - - OsuScoreProcessor scoreProcessor; - Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - - var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - var multiplayerUsers = new List(); - - foreach (int user in users) - { - SpectatorClient.SendStartPlay(user, Beatmap.Value.BeatmapInfo.OnlineID); - multiplayerUsers.Add(OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = user }, true)); - } - - Children = new Drawable[] - { - scoreProcessor = new OsuScoreProcessor(), - }; - - scoreProcessor.ApplyBeatmap(playableBeatmap); - - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, Add); - }); - - AddUntilStep("wait for load", () => leaderboard.IsLoaded); - AddUntilStep("wait for user population", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count > 0); - } - - [Test] - public void TestScoreUpdates() - { - AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 100); - AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded); - } - - [Test] - public void TestUserQuit() - { - foreach (int user in users) - AddStep($"mark user {user} quit", () => MultiplayerClient.RemoveUser(UserLookupCache.GetUserAsync(user).GetResultSafely().AsNonNull())); - } - - [Test] - public void TestChangeScoringMode() - { - AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 5); - AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic)); - AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); - } - - protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); - - protected class TestDependencies : MultiplayerTestSceneDependencies - { - protected override TestSpectatorClient CreateSpectatorClient() => new TestMultiplayerSpectatorClient(); - } - - public class TestMultiplayerSpectatorClient : TestSpectatorClient - { - private readonly Dictionary lastHeaders = new Dictionary(); - - public void RandomlyUpdateState() - { - foreach ((int userId, _) in WatchedUserStates) - { - if (RNG.NextBool()) - continue; - - if (!lastHeaders.TryGetValue(userId, out var header)) - { - lastHeaders[userId] = header = new FrameHeader(new ScoreInfo - { - Statistics = new Dictionary - { - [HitResult.Miss] = 0, - [HitResult.Meh] = 0, - [HitResult.Great] = 0 - } - }); - } - - switch (RNG.Next(0, 3)) - { - case 0: - header.Combo = 0; - header.Statistics[HitResult.Miss]++; - break; - - case 1: - header.Combo++; - header.MaxCombo = Math.Max(header.MaxCombo, header.Combo); - header.Statistics[HitResult.Meh]++; - break; - - default: - header.Combo++; - header.MaxCombo = Math.Max(header.MaxCombo, header.Combo); - header.Statistics[HitResult.Great]++; - break; - } - - ((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) })); - } - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index 7f5aced925..5cd3bb2f0f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -1,121 +1,56 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; -using NUnit.Framework; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Utils; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; -using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play.HUD; -using osu.Game.Tests.Visual.OnlinePlay; -using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerTestScene + public class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerGameplayLeaderboardTestScene { - private static IEnumerable users => Enumerable.Range(0, 16); - - public new TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient SpectatorClient => - (TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient)OnlinePlayDependencies?.SpectatorClient; - - protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); - - protected class TestDependencies : MultiplayerTestSceneDependencies + protected override MultiplayerRoomUser CreateUser(int userId) { - protected override TestSpectatorClient CreateSpectatorClient() => new TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient(); + var user = base.CreateUser(userId); + user.MatchState = new TeamVersusUserState + { + TeamID = RNG.Next(0, 2) + }; + return user; } - private MultiplayerGameplayLeaderboard leaderboard; - private GameplayMatchScoreDisplay gameplayScoreDisplay; - - protected override Room CreateRoom() - { - var room = base.CreateRoom(); - room.Type.Value = MatchType.TeamVersus; - return room; - } + protected override MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor) => + new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, MultiplayerUsers.ToArray()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; public override void SetUpSteps() { base.SetUpSteps(); - AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = UserLookupCache.GetUserAsync(1).GetResultSafely()); - - AddStep("create leaderboard", () => + AddStep("Add external display components", () => { - leaderboard?.Expire(); - - OsuScoreProcessor scoreProcessor; - Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - - var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - var multiplayerUsers = new List(); - - foreach (int user in users) + LoadComponentAsync(new MatchScoreDisplay { - SpectatorClient.SendStartPlay(user, Beatmap.Value.BeatmapInfo.OnlineID); - var roomUser = OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = user }, true); + Team1Score = { BindTarget = Leaderboard.TeamScores[0] }, + Team2Score = { BindTarget = Leaderboard.TeamScores[1] } + }, Add); - roomUser.MatchState = new TeamVersusUserState - { - TeamID = RNG.Next(0, 2) - }; - - multiplayerUsers.Add(roomUser); - } - - Children = new Drawable[] + LoadComponentAsync(new GameplayMatchScoreDisplay { - scoreProcessor = new OsuScoreProcessor(), - }; - - scoreProcessor.ApplyBeatmap(playableBeatmap); - - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, gameplayLeaderboard => - { - LoadComponentAsync(new MatchScoreDisplay - { - Team1Score = { BindTarget = leaderboard.TeamScores[0] }, - Team2Score = { BindTarget = leaderboard.TeamScores[1] } - }, Add); - - LoadComponentAsync(gameplayScoreDisplay = new GameplayMatchScoreDisplay - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Team1Score = { BindTarget = leaderboard.TeamScores[0] }, - Team2Score = { BindTarget = leaderboard.TeamScores[1] } - }, Add); - - Add(gameplayLeaderboard); - }); - }); - - AddUntilStep("wait for load", () => leaderboard.IsLoaded); - AddUntilStep("wait for user population", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count > 0); - } - - [Test] - public void TestScoreUpdates() - { - AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 100); - AddToggleStep("switch compact mode", expanded => - { - leaderboard.Expanded.Value = expanded; - gameplayScoreDisplay.Expanded.Value = expanded; + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Team1Score = { BindTarget = Leaderboard.TeamScores[0] }, + Team2Score = { BindTarget = Leaderboard.TeamScores[1] }, + Expanded = { BindTarget = Leaderboard.Expanded }, + }, Add); }); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index 6536ef2ca1..111f51675d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; namespace osu.Game.Tests.Visual.Multiplayer @@ -13,13 +14,19 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public new void Setup() => Schedule(() => { - Child = new Container + Child = new PopoverContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = 50, - Child = new MultiplayerMatchFooter() + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 50, + Child = new MultiplayerMatchFooter() + } }; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 381b9b58bd..714951cc42 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -8,13 +8,12 @@ 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.Database; using osu.Game.Online.Rooms; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; @@ -27,6 +26,7 @@ using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Select; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Multiplayer { @@ -35,10 +35,12 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager manager; private RulesetStore rulesets; - private List beatmaps; + private IList beatmaps => importedBeatmapSet?.PerformRead(s => s.Beatmaps) ?? new List(); private TestMultiplayerMatchSongSelect songSelect; + private Live importedBeatmapSet; + [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { @@ -46,44 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); - beatmaps = new List(); - - var metadata = new BeatmapMetadata - { - Artist = "Some Artist", - Title = "Some Beatmap", - Author = { Username = "Some Author" }, - }; - - var beatmapSetInfo = new BeatmapSetInfo - { - OnlineID = 10, - Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), - DateAdded = DateTimeOffset.UtcNow - }; - - for (int i = 0; i < 8; ++i) - { - int beatmapId = 10 * 10 + i; - - int length = RNG.Next(30000, 200000); - double bpm = RNG.NextSingle(80, 200); - - var beatmap = new BeatmapInfo - { - Ruleset = rulesets.GetRuleset(i % 4) ?? throw new InvalidOperationException(), - OnlineID = beatmapId, - Length = length, - BPM = bpm, - Metadata = metadata, - Difficulty = new BeatmapDifficulty() - }; - - beatmaps.Add(beatmap); - beatmapSetInfo.Beatmaps.Add(beatmap); - } - - manager.Import(beatmapSetInfo); + importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray())); } public override void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs index f95e73ff3c..b0a977dcbb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs @@ -1,67 +1,68 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using Moq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Lounge.Components; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneRankRangePill : MultiplayerTestScene + public class TestSceneRankRangePill : OsuTestScene { - [SetUp] - public new void Setup() => Schedule(() => + private readonly Mock multiplayerClient = new Mock(); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + // not used directly in component, but required due to it inheriting from OnlinePlayComposite. + new CachedModelDependencyContainer(base.CreateChildDependencies(parent)); + + [BackgroundDependencyLoader] + private void load() { + Dependencies.CacheAs(multiplayerClient.Object); + Child = new RankRangePill { Anchor = Anchor.Centre, Origin = Anchor.Centre }; - }); + } [Test] public void TestSingleUser() { - AddStep("add user", () => + setupRoomWithUsers(new APIUser { - MultiplayerClient.AddUser(new APIUser - { - Id = 2, - Statistics = { GlobalRank = 1234 } - }); - - // Remove the local user so only the one above is displayed. - MultiplayerClient.RemoveUser(API.LocalUser.Value); + Id = 2, + Statistics = { GlobalRank = 1234 } }); } [Test] public void TestMultipleUsers() { - AddStep("add users", () => - { - MultiplayerClient.AddUser(new APIUser + setupRoomWithUsers( + new APIUser { Id = 2, Statistics = { GlobalRank = 1234 } - }); - - MultiplayerClient.AddUser(new APIUser + }, + new APIUser { Id = 3, Statistics = { GlobalRank = 3333 } - }); - - MultiplayerClient.AddUser(new APIUser + }, + new APIUser { Id = 4, Statistics = { GlobalRank = 4321 } }); - - // Remove the local user so only the ones above are displayed. - MultiplayerClient.RemoveUser(API.LocalUser.Value); - }); } [TestCase(1, 10)] @@ -73,22 +74,29 @@ namespace osu.Game.Tests.Visual.Multiplayer [TestCase(1000000, 10000000)] public void TestRange(int min, int max) { - AddStep("add users", () => - { - MultiplayerClient.AddUser(new APIUser + setupRoomWithUsers( + new APIUser { Id = 2, Statistics = { GlobalRank = min } - }); - - MultiplayerClient.AddUser(new APIUser + }, + new APIUser { Id = 3, Statistics = { GlobalRank = max } }); + } - // Remove the local user so only the ones above are displayed. - MultiplayerClient.RemoveUser(API.LocalUser.Value); + private void setupRoomWithUsers(params APIUser[] users) + { + AddStep("setup room", () => + { + multiplayerClient.SetupGet(m => m.Room).Returns(new MultiplayerRoom(0) + { + Users = new List(users.Select(apiUser => new MultiplayerRoomUser(apiUser.Id) { User = apiUser })) + }); + + multiplayerClient.Raise(m => m.RoomUpdated -= null); }); } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 80a6698761..4d1dee1650 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -534,11 +534,33 @@ namespace osu.Game.Tests.Visual.Online }); }); - AddStep("Highlight message and open chat", () => + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel1)); + } + + [Test] + public void TestHighlightWithNullChannel() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + + AddStep("Send message in channel 1", () => { - chatOverlay.HighlightMessage(message, channel1); - chatOverlay.Show(); + channel1.AddNewMessages(message = new Message + { + ChannelId = channel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); }); + + AddStep("Set null channel", () => channelManager.CurrentChannel.Value = null); + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel1)); } private void pressChannelHotkey(int number) diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index ee9a0e263b..f5fe00458a 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -259,7 +259,7 @@ namespace osu.Game.Tests.Visual.Playlists { multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore { - ID = --highestScoreId, + ID = getNextLowestScoreId(), Accuracy = userScore.Accuracy, Passed = true, Rank = userScore.Rank, @@ -274,7 +274,7 @@ namespace osu.Game.Tests.Visual.Playlists multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore { - ID = ++lowestScoreId, + ID = getNextHighestScoreId(), Accuracy = userScore.Accuracy, Passed = true, Rank = userScore.Rank, @@ -306,7 +306,7 @@ namespace osu.Game.Tests.Visual.Playlists { result.Scores.Add(new MultiplayerScore { - ID = sort == "score_asc" ? --highestScoreId : ++lowestScoreId, + ID = sort == "score_asc" ? getNextHighestScoreId() : getNextLowestScoreId(), Accuracy = 1, Passed = true, Rank = ScoreRank.X, @@ -327,6 +327,17 @@ namespace osu.Game.Tests.Visual.Playlists return result; } + /// + /// The next highest score ID to appear at the left of the list. Monotonically decreasing. + /// + private int getNextHighestScoreId() => --highestScoreId; + + /// + /// The next lowest score ID to appear at the right of the list. Monotonically increasing. + /// + /// + private int getNextLowestScoreId() => ++lowestScoreId; + private void addCursor(MultiplayerScores scores) { scores.Cursor = new Cursor @@ -342,7 +353,9 @@ namespace osu.Game.Tests.Visual.Playlists { Properties = new Dictionary { - { "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_asc" : "score_desc") } + // [ 1, 2, 3, ... ] => score_desc (will be added to the right of the list) + // [ 3, 2, 1, ... ] => score_asc (will be added to the left of the list) + { "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_desc" : "score_asc") } } }; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 13404a9810..72dfc53c01 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +16,6 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; -using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; @@ -60,20 +58,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.Centre, Size = new Vector2(550f, 450f), Scope = BeatmapLeaderboardScope.Local, - BeatmapInfo = new BeatmapInfo - { - ID = Guid.NewGuid(), - Metadata = new BeatmapMetadata - { - Title = "TestSong", - Artist = "TestArtist", - Author = new RealmUser - { - Username = "TestAuthor" - }, - }, - DifficultyName = "Insane" - }, + BeatmapInfo = TestResources.CreateTestBeatmapSetInfo().Beatmaps.First() } }, dialogOverlay = new DialogOverlay() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs new file mode 100644 index 0000000000..34d9ddcc4e --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneModSelectScreen : OsuManualInputManagerTestScene + { + [Resolved] + private RulesetStore rulesetStore { get; set; } + + private ModSelectScreen modSelectScreen; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear contents", Clear); + AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); + AddStep("reset mods", () => SelectedMods.SetDefault()); + } + + private void createScreen() + { + AddStep("create screen", () => Child = modSelectScreen = new ModSelectScreen + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods } + }); + waitForColumnLoad(); + } + + [Test] + public void TestStateChange() + { + createScreen(); + AddStep("toggle state", () => modSelectScreen.ToggleVisibility()); + } + + [Test] + public void TestPreexistingSelection() + { + AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() }); + createScreen(); + AddUntilStep("two panels active", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + AddAssert("mod multiplier correct", () => + { + double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); + return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType().Single().Current.Value); + }); + assertCustomisationToggleState(disabled: false, active: false); + } + + [Test] + public void TestExternalSelection() + { + createScreen(); + AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() }); + AddUntilStep("two panels active", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + AddAssert("mod multiplier correct", () => + { + double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); + return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType().Single().Current.Value); + }); + assertCustomisationToggleState(disabled: false, active: false); + } + + [Test] + public void TestRulesetChange() + { + createScreen(); + changeRuleset(0); + changeRuleset(1); + changeRuleset(2); + changeRuleset(3); + } + + [Test] + public void TestCustomisationToggleState() + { + createScreen(); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select customisable mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("dismiss mod customisation", () => + { + InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("append another mod not requiring config", () => SelectedMods.Value = SelectedMods.Value.Append(new OsuModFlashlight()).ToArray()); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); + assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. + } + + private void waitForColumnLoad() => AddUntilStep("all column content loaded", + () => modSelectScreen.ChildrenOfType().Any() && modSelectScreen.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); + + private void changeRuleset(int id) + { + AddStep($"set ruleset to {id}", () => Ruleset.Value = rulesetStore.GetRuleset(id)); + waitForColumnLoad(); + } + + private void assertCustomisationToggleState(bool disabled, bool active) + { + ShearedToggleButton getToggle() => modSelectScreen.ChildrenOfType().Single(); + + AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => getToggle().Active.Disabled == disabled); + AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => getToggle().Active.Value == active); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index 6bd6115e68..b5f2544071 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -4,25 +4,58 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; using osu.Game.Overlays.Dialog; +using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - [TestFixture] - public class TestScenePopupDialog : OsuTestScene + public class TestScenePopupDialog : OsuManualInputManagerTestScene { - public TestScenePopupDialog() + private TestPopupDialog dialog; + + [SetUpSteps] + public void SetUpSteps() { AddStep("new popup", () => - Add(new TestPopupDialog + { + Add(dialog = new TestPopupDialog { RelativeSizeAxes = Axes.Both, State = { Value = Framework.Graphics.Containers.Visibility.Visible }, - })); + }); + }); + } + + [Test] + public void TestDangerousButton([Values(false, true)] bool atEdge) + { + if (atEdge) + { + AddStep("move mouse to button edge", () => + { + var dangerousButtonQuad = dialog.DangerousButton.ScreenSpaceDrawQuad; + InputManager.MoveMouseTo(new Vector2(dangerousButtonQuad.TopLeft.X + 5, dangerousButtonQuad.Centre.Y)); + }); + } + else + AddStep("move mouse to button", () => InputManager.MoveMouseTo(dialog.DangerousButton)); + + AddStep("click button", () => InputManager.Click(MouseButton.Left)); + AddAssert("action not invoked", () => !dialog.DangerousButtonInvoked); + + AddStep("hold button", () => InputManager.PressButton(MouseButton.Left)); + AddUntilStep("action invoked", () => dialog.DangerousButtonInvoked); + AddStep("release button", () => InputManager.ReleaseButton(MouseButton.Left)); } private class TestPopupDialog : PopupDialog { + public PopupDialogDangerousButton DangerousButton { get; } + + public bool DangerousButtonInvoked; + public TestPopupDialog() { Icon = FontAwesome.Solid.AssistiveListeningSystems; @@ -40,9 +73,10 @@ namespace osu.Game.Tests.Visual.UserInterface { Text = @"You're a fake!", }, - new PopupDialogDangerousButton + DangerousButton = new PopupDialogDangerousButton { Text = @"Careful with this one..", + Action = () => DangerousButtonInvoked = true, }, }; } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 397d47c389..bb64ec796c 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -17,7 +17,6 @@ using osu.Framework.Logging; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -152,24 +151,7 @@ namespace osu.Game.Beatmaps { const double excess_length = 1000; - var lastObject = Beatmap?.HitObjects.LastOrDefault(); - - double length; - - switch (lastObject) - { - case null: - length = emptyLength; - break; - - case IHasDuration endTime: - length = endTime.EndTime + excess_length; - break; - - default: - length = lastObject.StartTime + excess_length; - break; - } + double length = (BeatmapInfo?.Length + excess_length) ?? emptyLength; return audioManager.Tracks.GetVirtual(length); } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 2f966ac0a9..9aacb50684 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Globalization; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; @@ -102,6 +103,9 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.MenuParallax, true); + // See https://stackoverflow.com/a/63307411 for default sourcing. + SetDefault(OsuSetting.Prefer24HourTime, CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains(@"tt")); + // Gameplay SetDefault(OsuSetting.PositionalHitsounds, true); // replaced by level setting below, can be removed 20220703. SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1); @@ -287,6 +291,7 @@ namespace osu.Game.Configuration MenuVoice, CursorRotation, MenuParallax, + Prefer24HourTime, BeatmapDetailTab, BeatmapDetailModsFilter, Username, diff --git a/osu.Game/Graphics/ParticleSpewer.cs b/osu.Game/Graphics/ParticleSpewer.cs index 4fc6c4527f..369a4b21c7 100644 --- a/osu.Game/Graphics/ParticleSpewer.cs +++ b/osu.Game/Graphics/ParticleSpewer.cs @@ -109,6 +109,9 @@ namespace osu.Game.Graphics { foreach (var p in particles) { + if (p.Duration == 0) + continue; + float timeSinceStart = currentTime - p.StartTime; // ignore particles from the future. diff --git a/osu.Game/IO/Legacy/SerializationReader.cs b/osu.Game/IO/Legacy/SerializationReader.cs index f7b3f33e87..5423485c95 100644 --- a/osu.Game/IO/Legacy/SerializationReader.cs +++ b/osu.Game/IO/Legacy/SerializationReader.cs @@ -3,11 +3,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; -using System.Reflection; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Formatters.Binary; using System.Text; namespace osu.Game.IO.Legacy @@ -26,15 +22,6 @@ namespace osu.Game.IO.Legacy public int RemainingBytes => (int)(stream.Length - stream.Position); - /// Static method to take a SerializationInfo object (an input to an ISerializable constructor) - /// and produce a SerializationReader from which serialized objects can be read . - public static SerializationReader GetReader(SerializationInfo info) - { - byte[] byteArray = (byte[])info.GetValue("X", typeof(byte[])); - MemoryStream ms = new MemoryStream(byteArray); - return new SerializationReader(ms); - } - /// Reads a string from the buffer. Overrides the base implementation so it can cope with nulls. public override string ReadString() { @@ -186,98 +173,12 @@ namespace osu.Game.IO.Legacy return ReadCharArray(); case ObjType.otherType: - return DynamicDeserializer.Deserialize(BaseStream); + throw new IOException("Deserialization of arbitrary type is not supported."); default: return null; } } - - public static class DynamicDeserializer - { - private static VersionConfigToNamespaceAssemblyObjectBinder versionBinder; - private static BinaryFormatter formatter; - - private static void initialize() - { - versionBinder = new VersionConfigToNamespaceAssemblyObjectBinder(); - formatter = new BinaryFormatter - { - // AssemblyFormat = FormatterAssemblyStyle.Simple, - Binder = versionBinder - }; - } - - public static object Deserialize(Stream stream) - { - if (formatter == null) - initialize(); - - Debug.Assert(formatter != null, "formatter != null"); - - // ReSharper disable once PossibleNullReferenceException - return formatter.Deserialize(stream); - } - - #region Nested type: VersionConfigToNamespaceAssemblyObjectBinder - - public sealed class VersionConfigToNamespaceAssemblyObjectBinder : SerializationBinder - { - private readonly Dictionary cache = new Dictionary(); - - public override Type BindToType(string assemblyName, string typeName) - { - if (cache.TryGetValue(assemblyName + typeName, out var typeToDeserialize)) - return typeToDeserialize; - - List tmpTypes = new List(); - Type genType = null; - - if (typeName.Contains("System.Collections.Generic") && typeName.Contains("[[")) - { - string[] splitTypes = typeName.Split('['); - - foreach (string typ in splitTypes) - { - if (typ.Contains("Version")) - { - string asmTmp = typ.Substring(typ.IndexOf(',') + 1); - string asmName = asmTmp.Remove(asmTmp.IndexOf(']')).Trim(); - string typName = typ.Remove(typ.IndexOf(',')); - tmpTypes.Add(BindToType(asmName, typName)); - } - else if (typ.Contains("Generic")) - { - genType = BindToType(assemblyName, typ); - } - } - - if (genType != null && tmpTypes.Count > 0) - { - return genType.MakeGenericType(tmpTypes.ToArray()); - } - } - - string toAssemblyName = assemblyName.Split(',')[0]; - Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); - - foreach (Assembly a in assemblies) - { - if (a.FullName.Split(',')[0] == toAssemblyName) - { - typeToDeserialize = a.GetType(typeName); - break; - } - } - - cache.Add(assemblyName + typeName, typeToDeserialize); - - return typeToDeserialize; - } - } - - #endregion - } } public enum ObjType : byte diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index 9ebeaf616e..c9fff05bcc 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -4,9 +4,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Formatters; -using System.Runtime.Serialization.Formatters.Binary; using System.Text; // ReSharper disable ConditionIsAlwaysTrueOrFalse (we're allowing nulls to be passed to the writer where the underlying class doesn't). @@ -218,25 +215,11 @@ namespace osu.Game.IO.Legacy break; default: - Write((byte)ObjType.otherType); - BinaryFormatter b = new BinaryFormatter - { - // AssemblyFormat = FormatterAssemblyStyle.Simple, - TypeFormat = FormatterTypeStyle.TypesWhenNeeded - }; - b.Serialize(BaseStream, obj); - break; + throw new IOException("Serialization of arbitrary type is not supported."); } // switch } // if obj==null } // WriteObject - /// Adds the SerializationWriter buffer to the SerializationInfo at the end of GetObjectData(). - public void AddToInfo(SerializationInfo info) - { - byte[] b = ((MemoryStream)BaseStream).ToArray(); - info.AddValue("X", b, typeof(byte[])); - } - public void WriteRawBytes(byte[] b) { base.Write(b); diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index a60e4891f4..c65e6c77f4 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -29,6 +29,11 @@ namespace osu.Game.Localisation /// public static LocalisableString PreferOriginalMetadataLanguage => new TranslatableString(getKey(@"prefer_original"), @"Prefer metadata in original language"); + /// + /// "Prefer 24-hour time display" + /// + public static LocalisableString Prefer24HourTimeDisplay => new TranslatableString(getKey(@"prefer_24_hour_time_display"), @"Prefer 24-hour time display"); + /// /// "Updates" /// diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index bcfec3cc0f..20c33db61f 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -178,8 +178,6 @@ namespace osu.Game.Online.Chat { notificationOverlay.Hide(); chatOverlay.HighlightMessage(message, channel); - chatOverlay.Show(); - return true; }; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index d6099e5f72..4c10949225 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Database; @@ -31,7 +32,7 @@ namespace osu.Game.Online.Multiplayer /// /// Invoked when any change occurs to the multiplayer room. /// - public event Action? RoomUpdated; + public virtual event Action? RoomUpdated; /// /// Invoked when a new user joins the room. @@ -41,7 +42,7 @@ namespace osu.Game.Online.Multiplayer /// /// Invoked when a user leaves the room of their own accord. /// - public event Action? UserLeft; + public virtual event Action? UserLeft; /// /// Invoked when a user was kicked from the room forcefully. @@ -87,12 +88,26 @@ namespace osu.Game.Online.Multiplayer /// /// The joined . /// - public MultiplayerRoom? Room { get; private set; } + public virtual MultiplayerRoom? Room + { + get + { + Debug.Assert(ThreadSafety.IsUpdateThread); + return room; + } + private set + { + Debug.Assert(ThreadSafety.IsUpdateThread); + room = value; + } + } + + private MultiplayerRoom? room; /// /// The users in the joined which are participating in the current gameplay loop. /// - public IBindableList CurrentMatchPlayingUserIds => PlayingUserIds; + public virtual IBindableList CurrentMatchPlayingUserIds => PlayingUserIds; protected readonly BindableList PlayingUserIds = new BindableList(); @@ -127,7 +142,7 @@ namespace osu.Game.Online.Multiplayer [BackgroundDependencyLoader] private void load() { - IsConnected.BindValueChanged(connected => + IsConnected.BindValueChanged(connected => Scheduler.Add(() => { // clean up local room state on server disconnect. if (!connected.NewValue && Room != null) @@ -135,7 +150,7 @@ namespace osu.Game.Online.Multiplayer Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); LeaveRoom(); } - }); + })); } private readonly TaskChain joinOrLeaveTaskChain = new TaskChain(); @@ -148,13 +163,13 @@ namespace osu.Game.Online.Multiplayer /// An optional password to use for the join operation. public async Task JoinRoom(Room room, string? password = null) { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + var cancellationSource = joinCancellationSource = new CancellationTokenSource(); await joinOrLeaveTaskChain.Add(async () => { - if (Room != null) - throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); - Debug.Assert(room.RoomID.Value != null); // Join the server-side room. @@ -166,8 +181,10 @@ namespace osu.Game.Online.Multiplayer await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); // Update the stored room (must be done on update thread for thread-safety). - await scheduleAsync(() => + await runOnUpdateThreadAsync(() => { + Debug.Assert(Room == null); + Room = joinedRoom; APIRoom = room; @@ -213,7 +230,7 @@ namespace osu.Game.Online.Multiplayer // 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(() => + var scheduledReset = runOnUpdateThreadAsync(() => { APIRoom = null; Room = null; @@ -343,9 +360,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -378,9 +392,6 @@ namespace osu.Game.Online.Multiplayer async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { - if (Room == null) - return; - await PopulateUser(user).ConfigureAwait(false); Scheduler.Add(() => @@ -429,9 +440,6 @@ namespace osu.Game.Online.Multiplayer private Task handleUserLeft(MultiplayerRoomUser user, Action? callback) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -453,9 +461,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.HostChanged(int userId) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -476,26 +481,21 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { - Debug.Assert(APIRoom != null); - Debug.Assert(Room != null); - Scheduler.Add(() => updateLocalRoomSettings(newSettings)); - return Task.CompletedTask; } Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { - if (Room == null) + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. + if (user == null) return; - Room.Users.Single(u => u.UserID == userId).State = state; - + user.State = state; updateUserPlayingState(userId, state); RoomUpdated?.Invoke(); @@ -506,15 +506,15 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.MatchUserStateChanged(int userId, MatchUserState state) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { - if (Room == null) + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. + if (user == null) return; - Room.Users.Single(u => u.UserID == userId).MatchState = state; + user.MatchState = state; RoomUpdated?.Invoke(); }, false); @@ -523,9 +523,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.MatchRoomStateChanged(MatchRoomState state) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -540,9 +537,6 @@ namespace osu.Game.Online.Multiplayer public Task MatchEvent(MatchServerEvent e) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -563,9 +557,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); @@ -584,9 +575,6 @@ namespace osu.Game.Online.Multiplayer public Task UserModsChanged(int userId, IEnumerable mods) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); @@ -605,9 +593,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.LoadRequested() { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -621,9 +606,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.MatchStarted() { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -637,9 +619,6 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.ResultsReady() { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -653,9 +632,6 @@ namespace osu.Game.Online.Multiplayer public Task PlaylistItemAdded(MultiplayerPlaylistItem item) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -675,9 +651,6 @@ namespace osu.Game.Online.Multiplayer public Task PlaylistItemRemoved(long playlistItemId) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -699,9 +672,6 @@ namespace osu.Game.Online.Multiplayer public Task PlaylistItemChanged(MultiplayerPlaylistItem item) { - if (Room == null) - return Task.CompletedTask; - Scheduler.Add(() => { if (Room == null) @@ -784,7 +754,7 @@ namespace osu.Game.Online.Multiplayer PlayingUserIds.Remove(userId); } - private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) + private Task runOnUpdateThreadAsync(Action action, CancellationToken cancellationToken = default) { var tcs = new TaskCompletionSource(); diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs new file mode 100644 index 0000000000..4729765084 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.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. + +#nullable enable + +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using osu.Framework.Logging; + +namespace osu.Game.Online.Multiplayer +{ + public static class MultiplayerClientExtensions + { + public static void FireAndForget(this Task task, Action? onSuccess = null, Action? onError = null) => + task.ContinueWith(t => + { + if (t.IsFaulted) + { + Exception? exception = t.Exception; + + if (exception is AggregateException ae) + exception = ae.InnerException; + + Debug.Assert(exception != null); + + string message = exception is HubException + // HubExceptions arrive with additional message context added, but we want to display the human readable message: + // "An unexpected error occurred invoking 'AddPlaylistItem' on the server.InvalidStateException: Can't enqueue more than 3 items at once." + // We generally use the message field for a user-parseable error (eventually to be replaced), so drop the first part for now. + ? exception.Message.Substring(exception.Message.IndexOf(':') + 1).Trim() + : exception.Message; + + Logger.Log(message, level: LogLevel.Important); + onError?.Invoke(exception); + } + else + { + onSuccess?.Invoke(); + } + }); + } +} diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 8f22078010..78beda6298 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -54,17 +54,17 @@ namespace osu.Game.Online.Spectator /// /// Called whenever new frames arrive from the server. /// - public event Action? OnNewFrames; + public virtual event Action? OnNewFrames; /// /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session. /// - public event Action? OnUserBeganPlaying; + public virtual event Action? OnUserBeganPlaying; /// /// Called whenever a user finishes a play session. /// - public event Action? OnUserFinishedPlaying; + public virtual event Action? OnUserFinishedPlaying; /// /// All users currently being watched. @@ -221,7 +221,7 @@ namespace osu.Game.Online.Spectator }); } - public void WatchUser(int userId) + public virtual void WatchUser(int userId) { Debug.Assert(ThreadSafety.IsUpdateThread); diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 3d39c7ce3a..64b972262b 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -315,7 +315,7 @@ namespace osu.Game.Overlays { Debug.Assert(channel.Id == message.ChannelId); - if (currentChannel.Value.Id != channel.Id) + if (currentChannel.Value?.Id != channel.Id) { if (!channel.Joined.Value) channel = channelManager.JoinChannel(channel); @@ -324,6 +324,8 @@ namespace osu.Game.Overlays } channel.HighlightedMessage.Value = message; + + Show(); } private float startDragChatHeight; diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs index 1911a4fa56..adc627e15b 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs @@ -12,37 +12,38 @@ namespace osu.Game.Overlays.Dialog { public class PopupDialogDangerousButton : PopupDialogButton { + private Box progressBox; + private DangerousConfirmContainer confirmContainer; + [BackgroundDependencyLoader] private void load(OsuColour colours) { ButtonColour = colours.Red3; - ColourContainer.Add(new ConfirmFillBox + ColourContainer.Add(progressBox = new Box { - Action = () => Action(), RelativeSizeAxes = Axes.Both, Blending = BlendingParameters.Additive, }); + + AddInternal(confirmContainer = new DangerousConfirmContainer + { + Action = () => Action(), + RelativeSizeAxes = Axes.Both, + }); } - private class ConfirmFillBox : HoldToConfirmContainer + protected override void LoadComplete() { - private Box box; + base.LoadComplete(); + confirmContainer.Progress.BindValueChanged(progress => progressBox.Width = (float)progress.NewValue, true); + } + + private class DangerousConfirmContainer : HoldToConfirmContainer + { protected override double? HoldActivationDelay => 500; - protected override void LoadComplete() - { - base.LoadComplete(); - - Child = box = new Box - { - RelativeSizeAxes = Axes.Both, - }; - - Progress.BindValueChanged(progress => box.Width = (float)progress.NewValue, true); - } - protected override bool OnMouseDown(MouseDownEvent e) { BeginConfirm(); diff --git a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs index 4fc3a904fa..248d4f288e 100644 --- a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs +++ b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs @@ -20,6 +20,8 @@ namespace osu.Game.Overlays.Mods { public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue { + public const float HEIGHT = 42; + public Bindable Current { get => current.Current; @@ -42,13 +44,12 @@ namespace osu.Game.Overlays.Mods [Resolved] private OverlayColourProvider colourProvider { get; set; } - private const float height = 42; private const float multiplier_value_area_width = 56; private const float transition_duration = 200; public DifficultyMultiplierDisplay() { - Height = height; + Height = HEIGHT; AutoSizeAxes = Axes.X; InternalChild = new Container @@ -145,8 +146,9 @@ namespace osu.Game.Overlays.Mods protected override void LoadComplete() { base.LoadComplete(); + current.BindValueChanged(_ => updateState(), true); - FinishTransforms(true); + // required to prevent the counter initially rolling up from 0 to 1 // due to `Current.Value` having a nonstandard default value of 1. multiplierCounter.SetCountWithoutRolling(Current.Value); diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 736a0205e2..f84ae4ac8a 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -31,6 +31,10 @@ namespace osu.Game.Overlays.Mods { public class ModColumn : CompositeDrawable { + public readonly Container TopLevelContent; + + public readonly ModType ModType; + private Func? filter; /// @@ -48,7 +52,8 @@ namespace osu.Game.Overlays.Mods } } - private readonly ModType modType; + public Bindable> SelectedMods = new Bindable>(Array.Empty()); + private readonly Key[]? toggleKeys; private readonly Bindable>> availableMods = new Bindable>>(); @@ -69,95 +74,103 @@ namespace osu.Game.Overlays.Mods public ModColumn(ModType modType, bool allowBulkSelection, Key[]? toggleKeys = null) { - this.modType = modType; + ModType = modType; this.toggleKeys = toggleKeys; Width = 320; RelativeSizeAxes = Axes.Y; Shear = new Vector2(ModPanel.SHEAR_X, 0); - CornerRadius = ModPanel.CORNER_RADIUS; - Masking = true; Container controlContainer; InternalChildren = new Drawable[] { - new Container - { - RelativeSizeAxes = Axes.X, - Height = header_height + ModPanel.CORNER_RADIUS, - Children = new Drawable[] - { - headerBackground = new Box - { - RelativeSizeAxes = Axes.X, - Height = header_height + ModPanel.CORNER_RADIUS - }, - headerText = new OsuTextFlowContainer(t => - { - t.Font = OsuFont.TorusAlternate.With(size: 17); - t.Shadow = false; - t.Colour = Colour4.Black; - }) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Shear = new Vector2(-ModPanel.SHEAR_X, 0), - Padding = new MarginPadding - { - Horizontal = 17, - Bottom = ModPanel.CORNER_RADIUS - } - } - } - }, - new Container + TopLevelContent = new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = header_height }, - Child = contentContainer = new Container + CornerRadius = ModPanel.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - BorderThickness = 3, - Children = new Drawable[] + new Container { - contentBackground = new Box + RelativeSizeAxes = Axes.X, + Height = header_height + ModPanel.CORNER_RADIUS, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both - }, - new GridContainer + headerBackground = new Box + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModPanel.CORNER_RADIUS + }, + headerText = new OsuTextFlowContainer(t => + { + t.Font = OsuFont.TorusAlternate.With(size: 17); + t.Shadow = false; + t.Colour = Colour4.Black; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(-ModPanel.SHEAR_X, 0), + Padding = new MarginPadding + { + Horizontal = 17, + Bottom = ModPanel.CORNER_RADIUS + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = header_height }, + Child = contentContainer = new Container { RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Masking = true, + CornerRadius = ModPanel.CORNER_RADIUS, + BorderThickness = 3, + Children = new Drawable[] { - new Dimension(GridSizeMode.AutoSize), - new Dimension() - }, - Content = new[] - { - new Drawable[] + contentBackground = new Box { - controlContainer = new Container - { - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 14 } - } + RelativeSizeAxes = Axes.Both }, - new Drawable[] + new GridContainer { - new OsuScrollContainer + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - Child = panelFlow = new FillFlowContainer + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 7), - Padding = new MarginPadding(7) + controlContainer = new Container + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 14 } + } + }, + new Drawable[] + { + new NestedVerticalScrollContainer + { + RelativeSizeAxes = Axes.Both, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = panelFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 7), + Padding = new MarginPadding(7) + } + } } } } @@ -193,7 +206,7 @@ namespace osu.Game.Overlays.Mods private void createHeaderText() { - IEnumerable headerTextWords = modType.Humanize(LetterCasing.Title).Split(' '); + IEnumerable headerTextWords = ModType.Humanize(LetterCasing.Title).Split(' '); if (headerTextWords.Count() > 1) { @@ -209,7 +222,7 @@ namespace osu.Game.Overlays.Mods { availableMods.BindTo(game.AvailableMods); - headerBackground.Colour = accentColour = colours.ForModType(modType); + headerBackground.Colour = accentColour = colours.ForModType(ModType); if (toggleAllCheckbox != null) { @@ -225,6 +238,12 @@ namespace osu.Game.Overlays.Mods { base.LoadComplete(); availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods)); + SelectedMods.BindValueChanged(_ => + { + // if a load is in progress, don't try to update the selection - the load flow will do so. + if (latestLoadTask == null) + updateActiveState(); + }); updateMods(); } @@ -232,7 +251,7 @@ namespace osu.Game.Overlays.Mods private void updateMods() { - var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(modType) ?? Array.Empty()).ToList(); + var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty()).ToList(); if (newMods.SequenceEqual(panelFlow.Children.Select(p => p.Mod))) return; @@ -250,11 +269,20 @@ namespace osu.Game.Overlays.Mods { panelFlow.ChildrenEnumerable = loaded; - foreach (var panel in panelFlow) - panel.Active.BindValueChanged(_ => updateToggleState()); - updateToggleState(); - + updateActiveState(); + updateToggleAllState(); updateFilter(); + + foreach (var panel in panelFlow) + { + panel.Active.BindValueChanged(_ => + { + updateToggleAllState(); + SelectedMods.Value = panel.Active.Value + ? SelectedMods.Value.Append(panel.Mod).ToArray() + : SelectedMods.Value.Except(new[] { panel.Mod }).ToArray(); + }); + } }, (cancellationTokenSource = new CancellationTokenSource()).Token); loadTask.ContinueWith(_ => { @@ -263,6 +291,12 @@ namespace osu.Game.Overlays.Mods }); } + private void updateActiveState() + { + foreach (var panel in panelFlow) + panel.Active.Value = SelectedMods.Value.Contains(panel.Mod, EqualityComparer.Default); + } + #region Bulk select / deselect private const double initial_multiple_selection_delay = 120; @@ -297,7 +331,7 @@ namespace osu.Game.Overlays.Mods } } - private void updateToggleState() + private void updateToggleAllState() { if (toggleAllCheckbox != null && !SelectionAnimationRunning) { @@ -399,7 +433,7 @@ namespace osu.Game.Overlays.Mods foreach (var modPanel in panelFlow) modPanel.ApplyFilter(Filter); - updateToggleState(); + updateToggleAllState(); } #endregion diff --git a/osu.Game/Overlays/Mods/ModSelectScreen.cs b/osu.Game/Overlays/Mods/ModSelectScreen.cs new file mode 100644 index 0000000000..62080ec1b5 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSelectScreen.cs @@ -0,0 +1,392 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Layout; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Overlays.Mods +{ + public class ModSelectScreen : OsuFocusedOverlayContainer + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Cached] + public Bindable> SelectedMods { get; private set; } = new Bindable>(Array.Empty()); + + protected override bool StartHidden => true; + + private readonly BindableBool customisationVisible = new BindableBool(); + + private DifficultyMultiplierDisplay multiplierDisplay; + private ModSettingsArea modSettingsArea; + private FillFlowContainer columnFlow; + private GridContainer grid; + private Container mainContent; + + private PopupScreenTitle header; + private Container footer; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + RelativePositionAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + mainContent = new Container + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + grid = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 75), + }, + Content = new[] + { + new Drawable[] + { + header = new PopupScreenTitle + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Title = "Mod Select", + Description = "Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun.", + Close = Hide + } + }, + new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.X, + RelativePositionAxes = Axes.X, + X = 0.3f, + Height = DifficultyMultiplierDisplay.HEIGHT, + Margin = new MarginPadding + { + Horizontal = 100, + Vertical = 10 + }, + Child = multiplierDisplay = new DifficultyMultiplierDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + }, + new Drawable[] + { + new Container + { + Depth = float.MaxValue, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Children = new Drawable[] + { + new OsuScrollContainer(Direction.Horizontal) + { + RelativeSizeAxes = Axes.Both, + Masking = false, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = columnFlow = new ModColumnContainer + { + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Right = 70 }, + Children = new[] + { + new ModColumn(ModType.DifficultyReduction, false, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }), + new ModColumn(ModType.DifficultyIncrease, false, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }), + new ModColumn(ModType.Automation, false, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }), + new ModColumn(ModType.Conversion, false), + new ModColumn(ModType.Fun, false) + } + } + } + } + } + }, + new[] { Empty() } + } + }, + footer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.X, + Height = 50, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = colourProvider.Background5 + }, + new ShearedToggleButton(200) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Vertical = 14, Left = 70 }, + Text = "Mod Customisation", + Active = { BindTarget = customisationVisible } + } + } + }, + new ClickToReturnContainer + { + RelativeSizeAxes = Axes.Both, + HandleMouse = { BindTarget = customisationVisible }, + OnClicked = () => customisationVisible.Value = false + } + } + }, + modSettingsArea = new ModSettingsArea + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 0 + } + }; + + columnFlow.Shear = new Vector2(ModPanel.SHEAR_X, 0); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); + + SelectedMods.BindValueChanged(val => + { + updateMultiplier(); + updateCustomisation(val); + updateSelectionFromBindable(); + }, true); + + foreach (var column in columnFlow) + { + column.SelectedMods.BindValueChanged(_ => updateBindableFromSelection()); + } + + customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); + } + + private void updateMultiplier() + { + double multiplier = 1.0; + + foreach (var mod in SelectedMods.Value) + multiplier *= mod.ScoreMultiplier; + + multiplierDisplay.Current.Value = multiplier; + } + + private void updateCustomisation(ValueChangedEvent> valueChangedEvent) + { + bool anyCustomisableMod = false; + bool anyModWithRequiredCustomisationAdded = false; + + foreach (var mod in SelectedMods.Value) + { + anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); + anyModWithRequiredCustomisationAdded |= !valueChangedEvent.OldValue.Contains(mod) && mod.RequiresConfiguration; + } + + if (anyCustomisableMod) + { + customisationVisible.Disabled = false; + + if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value) + customisationVisible.Value = true; + } + else + { + if (customisationVisible.Value) + customisationVisible.Value = false; + + customisationVisible.Disabled = true; + } + } + + private void updateCustomisationVisualState() + { + const double transition_duration = 300; + + grid.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); + + float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; + + modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); + mainContent.TransformTo(nameof(Margin), new MarginPadding { Bottom = modAreaHeight }, transition_duration, Easing.InOutCubic); + } + + private bool selectionBindableSyncInProgress; + + private void updateSelectionFromBindable() + { + if (selectionBindableSyncInProgress) + return; + + selectionBindableSyncInProgress = true; + + foreach (var column in columnFlow) + column.SelectedMods.Value = SelectedMods.Value.Where(mod => mod.Type == column.ModType).ToArray(); + + selectionBindableSyncInProgress = false; + } + + private void updateBindableFromSelection() + { + if (selectionBindableSyncInProgress) + return; + + selectionBindableSyncInProgress = true; + + SelectedMods.Value = columnFlow.SelectMany(column => column.SelectedMods.Value).ToArray(); + + selectionBindableSyncInProgress = false; + } + + protected override void PopIn() + { + const double fade_in_duration = 400; + + base.PopIn(); + this.FadeIn(fade_in_duration, Easing.OutQuint); + + header.MoveToY(0, fade_in_duration, Easing.OutQuint); + footer.MoveToY(0, fade_in_duration, Easing.OutQuint); + + multiplierDisplay + .Delay(fade_in_duration * 0.65f) + .FadeIn(fade_in_duration / 2, Easing.OutQuint) + .ScaleTo(1, fade_in_duration, Easing.OutElastic); + + for (int i = 0; i < columnFlow.Count; i++) + { + columnFlow[i].TopLevelContent + .Delay(i * 30) + .MoveToY(0, fade_in_duration, Easing.OutQuint) + .FadeIn(fade_in_duration, Easing.OutQuint); + } + } + + protected override void PopOut() + { + const double fade_out_duration = 500; + + base.PopOut(); + this.FadeOut(fade_out_duration, Easing.OutQuint); + + multiplierDisplay + .FadeOut(fade_out_duration / 2, Easing.OutQuint) + .ScaleTo(0.75f, fade_out_duration, Easing.OutQuint); + + header.MoveToY(-header.DrawHeight, fade_out_duration, Easing.OutQuint); + footer.MoveToY(footer.DrawHeight, fade_out_duration, Easing.OutQuint); + + for (int i = 0; i < columnFlow.Count; i++) + { + const float distance = 700; + + columnFlow[i].TopLevelContent + .MoveToY(i % 2 == 0 ? -distance : distance, fade_out_duration, Easing.OutQuint) + .FadeOut(fade_out_duration, Easing.OutQuint); + } + } + + private class ModColumnContainer : FillFlowContainer + { + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + public ModColumnContainer() + { + AddLayout(drawSizeLayout); + } + + public override void Add(ModColumn column) + { + base.Add(column); + + Debug.Assert(column != null); + column.Shear = Vector2.Zero; + } + + protected override void Update() + { + base.Update(); + + if (!drawSizeLayout.IsValid) + { + Padding = new MarginPadding + { + Left = DrawHeight * ModPanel.SHEAR_X, + Bottom = 10 + }; + + drawSizeLayout.Validate(); + } + } + } + + private class ClickToReturnContainer : Container + { + public BindableBool HandleMouse { get; } = new BindableBool(); + + public Action OnClicked { get; set; } + + protected override bool Handle(UIEvent e) + { + if (!HandleMouse.Value) + return base.Handle(e); + + switch (e) + { + case ClickEvent _: + OnClicked?.Invoke(); + return true; + + case MouseEvent _: + return true; + } + + return base.Handle(e); + } + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs index e0a30f60c2..be72c1e3e3 100644 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ b/osu.Game/Overlays/Mods/ModSettingsArea.cs @@ -23,6 +23,8 @@ namespace osu.Game.Overlays.Mods { public Bindable> SelectedMods { get; } = new Bindable>(); + public const float HEIGHT = 250; + private readonly Box background; private readonly FillFlowContainer modSettingsFlow; @@ -32,7 +34,7 @@ namespace osu.Game.Overlays.Mods public ModSettingsArea() { RelativeSizeAxes = Axes.X; - Height = 250; + Height = HEIGHT; Anchor = Anchor.BottomRight; Origin = Anchor.BottomRight; @@ -52,6 +54,7 @@ namespace osu.Game.Overlays.Mods { RelativeSizeAxes = Axes.Both, ScrollbarOverlapsContent = false, + ClampExtension = 100, Child = modSettingsFlow = new FillFlowContainer { AutoSizeAxes = Axes.X, @@ -155,9 +158,10 @@ namespace osu.Game.Overlays.Mods new[] { Empty() }, new Drawable[] { - new OsuScrollContainer(Direction.Vertical) + new NestedVerticalScrollContainer { RelativeSizeAxes = Axes.Both, + ClampExtension = 100, Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/Mods/NestedVerticalScrollContainer.cs b/osu.Game/Overlays/Mods/NestedVerticalScrollContainer.cs new file mode 100644 index 0000000000..aba47d5423 --- /dev/null +++ b/osu.Game/Overlays/Mods/NestedVerticalScrollContainer.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. + +#nullable enable + +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Mods +{ + /// + /// A scroll container that handles the case of vertically scrolling content inside a larger horizontally scrolling parent container. + /// + public class NestedVerticalScrollContainer : OsuScrollContainer + { + private OsuScrollContainer? parentScrollContainer; + + protected override void LoadComplete() + { + base.LoadComplete(); + + parentScrollContainer = this.FindClosestParent(); + } + + protected override bool OnScroll(ScrollEvent e) + { + if (parentScrollContainer == null) + return base.OnScroll(e); + + bool topRightInView = parentScrollContainer.ScreenSpaceDrawQuad.Contains(ScreenSpaceDrawQuad.TopRight); + bool bottomLeftInView = parentScrollContainer.ScreenSpaceDrawQuad.Contains(ScreenSpaceDrawQuad.BottomLeft); + + // If not completely on-screen, handle scroll but also allow parent to scroll at the same time (to hopefully bring our content into full view). + if (!topRightInView || !bottomLeftInView) + return false; + + bool scrollingPastEnd = e.ScrollDelta.Y < 0 && IsScrolledToEnd(); + bool scrollingPastStart = e.ScrollDelta.Y > 0 && Target <= 0; + + // If at either of our extents, delegate scroll to the horizontal parent container. + if (scrollingPastStart || scrollingPastEnd) + return false; + + return base.OnScroll(e); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 200618c469..cdce187a35 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Localisation; @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections.General protected override LocalisableString Header => GeneralSettingsStrings.LanguageHeader; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig) + private void load(FrameworkConfigManager frameworkConfig, OsuConfigManager config) { frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); @@ -34,6 +35,11 @@ namespace osu.Game.Overlays.Settings.Sections.General LabelText = GeneralSettingsStrings.PreferOriginalMetadataLanguage, Current = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode) }, + new SettingsCheckbox + { + LabelText = GeneralSettingsStrings.Prefer24HourTimeDisplay, + Current = config.GetBindable(OsuSetting.Prefer24HourTime) + }, }; if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale)) diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index 81a362450c..ac6f563336 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs @@ -29,6 +29,23 @@ namespace osu.Game.Overlays.Toolbar } } + private bool use24HourDisplay; + + public bool Use24HourDisplay + { + get => use24HourDisplay; + set + { + if (use24HourDisplay == value) + return; + + use24HourDisplay = value; + + updateMetrics(); + UpdateDisplay(DateTimeOffset.Now); //Update realTime.Text immediately instead of waiting until next second + } + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -50,13 +67,14 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateDisplay(DateTimeOffset now) { - realTime.Text = $"{now:HH:mm:ss}"; + realTime.Text = use24HourDisplay ? $"{now:HH:mm:ss}" : $"{now:h:mm:ss tt}"; gameTime.Text = $"running {new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; } private void updateMetrics() { - Width = showRuntime ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare). + Width = showRuntime || !use24HourDisplay ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare). + gameTime.FadeTo(showRuntime ? 1 : 0); } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs index 22a96603dc..308359570f 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs @@ -20,6 +20,7 @@ namespace osu.Game.Overlays.Toolbar public class ToolbarClock : OsuClickableContainer { private Bindable clockDisplayMode; + private Bindable prefer24HourTime; private Box hoverBackground; private Box flashBackground; @@ -38,6 +39,7 @@ namespace osu.Game.Overlays.Toolbar private void load(OsuConfigManager config) { clockDisplayMode = config.GetBindable(OsuSetting.ToolbarClockDisplayMode); + prefer24HourTime = config.GetBindable(OsuSetting.Prefer24HourTime); Children = new Drawable[] { @@ -94,6 +96,8 @@ namespace osu.Game.Overlays.Toolbar analog.FadeTo(showAnalog ? 1 : 0); }, true); + + prefer24HourTime.BindValueChanged(prefer24H => digital.Use24HourDisplay = prefer24H.NewValue, true); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index d4c4dce0f5..1c71f5d055 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; @@ -18,8 +16,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Zoooooooooom..."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModHalfTime)).ToArray(); - [SettingSource("Speed increase", "The actual increase to apply")] public override BindableNumber SpeedChange { get; } = new BindableDouble { diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index c240cdbe6e..13d89e30d6 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; @@ -18,8 +16,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override string Description => "Less zoom..."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModDoubleTime)).ToArray(); - [SettingSource("Speed decrease", "The actual decrease to apply")] public override BindableNumber SpeedChange { get; } = new BindableDouble { diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index ebe18f2188..88fb609c07 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; - public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed) }; + public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) }; public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; } diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index a6b54dd1f2..e569b0e517 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -147,7 +147,7 @@ namespace osu.Game.Screens.Menu bool loadThemedIntro() { - var setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash); + var setInfo = beatmaps.QueryBeatmapSet(b => b.Protected && b.Hash == BeatmapHash); if (setInfo == null) return false; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 5adce862a0..8e3aa77e7b 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -418,10 +418,16 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components var retrievedBeatmap = task.GetResultSafely(); statusText.Text = "Currently playing "; - beatmapText.AddLink(retrievedBeatmap.GetDisplayTitleRomanisable(), - LinkAction.OpenBeatmap, - retrievedBeatmap.OnlineID.ToString(), - creationParameters: s => s.Truncate = true); + + if (retrievedBeatmap != null) + { + beatmapText.AddLink(retrievedBeatmap.GetDisplayTitleRomanisable(), + LinkAction.OpenBeatmap, + retrievedBeatmap.OnlineID.ToString(), + creationParameters: s => s.Truncate = true); + } + else + beatmapText.AddText("unknown beatmap"); }), cancellationSource.Token); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 1201279929..d048676872 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -114,18 +114,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating; - void toggleReady() => Client.ToggleReady().ContinueWith(_ => endOperation()); + void toggleReady() => Client.ToggleReady().FireAndForget( + onSuccess: endOperation, + onError: _ => endOperation()); - void startMatch() => Client.StartMatch().ContinueWith(t => + void startMatch() => Client.StartMatch().FireAndForget(onSuccess: () => { - // accessing Exception here silences any potential errors from the antecedent task - if (t.Exception != null) - { - // gameplay was not started due to an exception; unblock button. - endOperation(); - } - // gameplay is starting, the button will be unblocked on load requested. + }, onError: _ => + { + // gameplay was not started due to an exception; unblock button. + endOperation(); }); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index d275f309cb..22a0243f8f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -30,13 +30,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private MultiplayerRoom room => multiplayerClient.Room; private Sample countdownTickSample; + private Sample countdownWarnSample; + private Sample countdownWarnFinalSample; [BackgroundDependencyLoader] private void load(AudioManager audio) { countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick"); - // disabled for now pending further work on sound effect - // countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final"); + countdownWarnSample = audio.Samples.Get(@"Multiplayer/countdown-warn"); + countdownWarnFinalSample = audio.Samples.Get(@"Multiplayer/countdown-warn-final"); } protected override void LoadComplete() @@ -102,8 +104,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void playTickSound(int secondsRemaining) { if (secondsRemaining < 10) countdownTickSample?.Play(); - // disabled for now pending further work on sound effect - // if (secondsRemaining <= 3) countdownTickFinalSample?.Play(); + + if (secondsRemaining <= 3) + { + if (secondsRemaining > 0) + countdownWarnSample?.Play(); + else + countdownWarnFinalSample?.Play(); + } } private void updateButtonText() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index 1653d416d8..d72ce5e960 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -62,7 +62,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist { base.LoadComplete(); - RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID); + RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID).FireAndForget(); multiplayerClient.RoomUpdated += onRoomUpdated; onRoomUpdated(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 28c9bef3f0..2482d52492 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer // If gameplay wasn't finished, then we have a simple path back to the idle state by aborting gameplay. if (!playerLoader.GameplayPassed) { - client.AbortGameplay(); + client.AbortGameplay().FireAndForget(); return; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index d49c122bd1..848424bc76 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -1,13 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; using osu.Framework.Allocation; -using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; @@ -76,40 +72,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem); - task.ContinueWith(t => + task.FireAndForget(onSuccess: () => Schedule(() => { - Schedule(() => - { - // If an error or server side trigger occurred this screen may have already exited by external means. - if (!this.IsCurrentScreen()) - return; - - loadingLayer.Hide(); - - if (t.IsFaulted) - { - Exception exception = t.Exception; - - if (exception is AggregateException ae) - exception = ae.InnerException; - - Debug.Assert(exception != null); - - string message = exception is HubException - // HubExceptions arrive with additional message context added, but we want to display the human readable message: - // "An unexpected error occurred invoking 'AddPlaylistItem' on the server.InvalidStateException: Can't enqueue more than 3 items at once." - // We generally use the message field for a user-parseable error (eventually to be replaced), so drop the first part for now. - ? exception.Message.Substring(exception.Message.IndexOf(':') + 1).Trim() - : exception.Message; - - Logger.Log(message, level: LogLevel.Important); - Carousel.AllowSelection = true; - return; - } + loadingLayer.Hide(); + // If an error or server side trigger occurred this screen may have already exited by external means. + if (this.IsCurrentScreen()) this.Exit(); - }); - }); + }), onError: _ => Schedule(() => + { + loadingLayer.Hide(); + Carousel.AllowSelection = true; + })); } else { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index e53153e017..7ef0bb5923 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -281,7 +281,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.Room == null) return; - client.ChangeUserMods(mods.NewValue); + client.ChangeUserMods(mods.NewValue).FireAndForget(); modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); modSettingChangeTracker.SettingChanged += onModSettingsChanged; @@ -296,7 +296,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.Room == null) return; - client.ChangeUserMods(UserMods.Value); + client.ChangeUserMods(UserMods.Value).FireAndForget(); }, 500); } @@ -305,7 +305,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.Room == null) return; - client.ChangeBeatmapAvailability(availability.NewValue); + client.ChangeBeatmapAvailability(availability.NewValue).FireAndForget(); if (availability.NewValue.State != DownloadState.LocallyAvailable) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 043315c790..70f8f1b752 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -133,6 +133,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer failAndBail(); } }), true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); Debug.Assert(client.Room != null); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 7ba0a63856..e091559046 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -169,7 +169,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Origin = Anchor.Centre, Alpha = 0, Margin = new MarginPadding(4), - Action = () => Client.KickUser(User.UserID), + Action = () => Client.KickUser(User.UserID).FireAndForget(), }, }, } @@ -231,7 +231,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants if (!Client.IsHost) return; - Client.TransferHost(targetUser); + Client.TransferHost(targetUser).FireAndForget(); }), new OsuMenuItem("Kick", MenuItemType.Destructive, () => { @@ -239,7 +239,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants if (!Client.IsHost) return; - Client.KickUser(targetUser); + Client.KickUser(targetUser).FireAndForget(); }) }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index 73aca0acdc..aca2c6073a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -83,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Client.SendMatchRequest(new ChangeTeamRequest { TeamID = ((Client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0, - }); + }).FireAndForget(); } public int? DisplayedTeam { get; private set; } diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs index 7a1f724cfb..8b1e0ae83e 100644 --- a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio.Track; @@ -80,13 +79,16 @@ namespace osu.Game.Screens.Play.HUD difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token) .ContinueWith(task => Schedule(() => { + if (task.Exception != null) + return; + timedAttributes = task.GetResultSafely(); IsValid = true; if (lastJudgement != null) onJudgementChanged(lastJudgement); - }), TaskContinuationOptions.OnlyOnRanToCompletion); + })); } } diff --git a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs index 6a74fdaf75..0c9c909395 100644 --- a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -87,31 +87,33 @@ namespace osu.Game.Screens.Ranking }); } - button.Enabled.Value = replayAvailability != ReplayAvailability.NotAvailable; - updateTooltip(); + updateState(); }, true); State.BindValueChanged(state => { button.State.Value = state.NewValue; - updateTooltip(); + updateState(); }, true); } - private void updateTooltip() + private void updateState() { switch (replayAvailability) { case ReplayAvailability.Local: button.TooltipText = @"watch replay"; + button.Enabled.Value = true; break; case ReplayAvailability.Online: button.TooltipText = @"download replay"; + button.Enabled.Value = true; break; default: button.TooltipText = @"replay unavailable"; + button.Enabled.Value = false; break; } } diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs index d67bfb89ab..ebf3c9c319 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -21,21 +21,20 @@ namespace osu.Game.Skinning.Editor private readonly List> targetComponents = new List>(); + [Resolved] + private SkinEditor editor { get; set; } + public SkinBlueprintContainer(Drawable target) { this.target = target; } - [BackgroundDependencyLoader(true)] - private void load(SkinEditor editor) - { - SelectedItems.BindTo(editor.SelectedComponents); - } - protected override void LoadComplete() { base.LoadComplete(); + SelectedItems.BindTo(editor.SelectedComponents); + // track each target container on the current screen. var targetContainers = target.ChildrenOfType().ToArray(); @@ -56,7 +55,7 @@ namespace osu.Game.Skinning.Editor } } - private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e) + private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => { switch (e.Action) { @@ -79,7 +78,7 @@ namespace osu.Game.Skinning.Editor AddBlueprintFor(item); break; } - } + }); protected override void AddBlueprintFor(ISkinnableDrawable item) { @@ -93,5 +92,13 @@ namespace osu.Game.Skinning.Editor protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) => new SkinBlueprint(component); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + foreach (var list in targetComponents) + list.UnbindAll(); + } } } diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index bcff70c008..e36d5ca3c6 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -203,6 +203,9 @@ namespace osu.Game.Skinning.Editor SelectedComponents.Clear(); + // Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target. + content?.Clear(); + Scheduler.AddOnce(loadBlueprintContainer); Scheduler.AddOnce(populateSettings); diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 92713023f4..f7d5581621 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -443,7 +443,9 @@ namespace osu.Game.Skinning string lookupName = name.Replace(@"@2x", string.Empty); float ratio = 2; - var texture = Textures?.Get(@$"{lookupName}@2x", wrapModeS, wrapModeT); + string twoTimesFilename = $"{Path.ChangeExtension(lookupName, null)}@2x{Path.GetExtension(lookupName)}"; + + var texture = Textures?.Get(twoTimesFilename, wrapModeS, wrapModeT); if (texture == null) { diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index 479afabb00..514a06a4ee 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -18,39 +20,32 @@ namespace osu.Game.Skinning { public static class LegacySkinExtensions { - [CanBeNull] - public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", - bool startAtCurrentTime = true, double? frameLength = null) + public static Drawable? GetAnimation(this ISkin? source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", + bool startAtCurrentTime = true, double? frameLength = null) => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength); - [CanBeNull] - public static Drawable GetAnimation(this ISkin source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, - string animationSeparator = "-", - bool startAtCurrentTime = true, double? frameLength = null) + public static Drawable? GetAnimation(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, + string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null) { - Texture texture; - - // find the first source which provides either the animated or non-animated version. - ISkin skin = (source as ISkinSource)?.FindProvider(s => - { - if (animatable && s.GetTexture(getFrameName(0)) != null) - return true; - - return s.GetTexture(componentName, wrapModeS, wrapModeT) != null; - }) ?? source; - - if (skin == null) + if (source == null) return null; - if (animatable) - { - var textures = getTextures().ToArray(); + var textures = GetTextures(source, componentName, wrapModeS, wrapModeT, animatable, animationSeparator, out var retrievalSource); + + switch (textures.Length) + { + case 0: + return null; + + case 1: + return new Sprite { Texture = textures[0] }; + + default: + Debug.Assert(retrievalSource != null); - if (textures.Length > 0) - { var animation = new SkinnableTextureAnimation(startAtCurrentTime) { - DefaultFrameLength = frameLength ?? getFrameLength(skin, applyConfigFrameRate, textures), + DefaultFrameLength = frameLength ?? getFrameLength(retrievalSource, applyConfigFrameRate, textures), Loop = looping, }; @@ -58,19 +53,46 @@ namespace osu.Game.Skinning animation.AddFrame(t); return animation; - } + } + } + + public static Texture[] GetTextures(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, string animationSeparator, out ISkin? retrievalSource) + { + retrievalSource = null; + + if (source == null) + return Array.Empty(); + + // find the first source which provides either the animated or non-animated version. + retrievalSource = (source as ISkinSource)?.FindProvider(s => + { + if (animatable && s.GetTexture(getFrameName(0)) != null) + return true; + + return s.GetTexture(componentName, wrapModeS, wrapModeT) != null; + }) ?? source; + + if (animatable) + { + var textures = getTextures(retrievalSource).ToArray(); + + if (textures.Length > 0) + return textures; } // if an animation was not allowed or not found, fall back to a sprite retrieval. - if ((texture = skin.GetTexture(componentName, wrapModeS, wrapModeT)) != null) - return new Sprite { Texture = texture }; + var singleTexture = retrievalSource.GetTexture(componentName, wrapModeS, wrapModeT); - return null; + return singleTexture != null + ? new[] { singleTexture } + : Array.Empty(); - IEnumerable getTextures() + IEnumerable getTextures(ISkin skin) { for (int i = 0; true; i++) { + Texture? texture; + if ((texture = skin.GetTexture(getFrameName(i), wrapModeS, wrapModeT)) == null) break; @@ -130,7 +152,7 @@ namespace osu.Game.Skinning public class SkinnableTextureAnimation : TextureAnimation { [Resolved(canBeNull: true)] - private IAnimationTimeReference timeReference { get; set; } + private IAnimationTimeReference? timeReference { get; set; } private readonly Bindable animationStartTime = new BindableDouble(); diff --git a/osu.Game/Skinning/SkinModelManager.cs b/osu.Game/Skinning/SkinModelManager.cs index 33e49ce486..23813e8eb2 100644 --- a/osu.Game/Skinning/SkinModelManager.cs +++ b/osu.Game/Skinning/SkinModelManager.cs @@ -104,7 +104,9 @@ namespace osu.Game.Skinning // For imports, we want to use the archive or folder name as part of the metadata, in addition to any existing skin.ini metadata. // In an ideal world, skin.ini would be the only source of metadata, but a lot of skin creators and users don't update it when making modifications. // In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin. - if (archiveName != item.Name) + if (archiveName != item.Name + // lazer exports use this format + && archiveName != item.GetDisplayString()) item.Name = @$"{item.Name} [{archiveName}]"; } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 88cb5f40a1..8a14b8b183 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -2,16 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardAnimation : DrawableAnimation, IFlippable, IVectorScalable + public class DrawableStoryboardAnimation : TextureAnimation, IFlippable, IVectorScalable { public StoryboardAnimation Animation { get; } @@ -88,17 +90,52 @@ namespace osu.Game.Storyboards.Drawables LifetimeEnd = animation.EndTime; } + [Resolved] + private ISkinSource skin { get; set; } + [BackgroundDependencyLoader] private void load(TextureStore textureStore, Storyboard storyboard) { - for (int frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++) + int frameIndex = 0; + + Texture frameTexture = storyboard.GetTextureFromPath(getFramePath(frameIndex), textureStore); + + if (frameTexture != null) { - string framePath = Animation.Path.Replace(".", frameIndex + "."); - Drawable frame = storyboard.CreateSpriteFromResourcePath(framePath, textureStore) ?? Empty(); - AddFrame(frame, Animation.FrameDelay); + // sourcing from storyboard. + for (frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++) + { + AddFrame(storyboard.GetTextureFromPath(getFramePath(frameIndex), textureStore), Animation.FrameDelay); + } + } + else if (storyboard.UseSkinSprites) + { + // fallback to skin if required. + skin.SourceChanged += skinSourceChanged; + skinSourceChanged(); } Animation.ApplyTransforms(this); } + + private void skinSourceChanged() + { + ClearFrames(); + + // When reading from a skin, we match stables weird behaviour where `FrameCount` is ignored + // and resources are retrieved until the end of the animation. + foreach (var texture in skin.GetTextures(Path.GetFileNameWithoutExtension(Animation.Path), default, default, true, string.Empty, out _)) + AddFrame(texture, Animation.FrameDelay); + } + + private string getFramePath(int i) => Animation.Path.Replace(".", $"{i}."); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (skin != null) + skin.SourceChanged -= skinSourceChanged; + } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index db10f13896..a6f2b8fcbd 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -4,14 +4,15 @@ using System; 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.Framework.Utils; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardSprite : CompositeDrawable, IFlippable, IVectorScalable + public class DrawableStoryboardSprite : Sprite, IFlippable, IVectorScalable { public StoryboardSprite Sprite { get; } @@ -85,19 +86,33 @@ namespace osu.Game.Storyboards.Drawables LifetimeStart = sprite.StartTime; LifetimeEnd = sprite.EndTime; - - AutoSizeAxes = Axes.Both; } + [Resolved] + private ISkinSource skin { get; set; } + [BackgroundDependencyLoader] private void load(TextureStore textureStore, Storyboard storyboard) { - var drawable = storyboard.CreateSpriteFromResourcePath(Sprite.Path, textureStore); + Texture = storyboard.GetTextureFromPath(Sprite.Path, textureStore); - if (drawable != null) - InternalChild = drawable; + if (Texture == null && storyboard.UseSkinSprites) + { + skin.SourceChanged += skinSourceChanged; + skinSourceChanged(); + } Sprite.ApplyTransforms(this); } + + private void skinSourceChanged() => Texture = skin.GetTexture(Sprite.Path); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (skin != null) + skin.SourceChanged -= skinSourceChanged; + } } } diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index b662b98e4e..1d21b5dce2 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -4,13 +4,10 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Rulesets.Mods; -using osu.Game.Skinning; using osu.Game.Storyboards.Drawables; namespace osu.Game.Storyboards @@ -94,25 +91,14 @@ namespace osu.Game.Storyboards public DrawableStoryboard CreateDrawable(IReadOnlyList mods = null) => new DrawableStoryboard(this, mods); - public Drawable CreateSpriteFromResourcePath(string path, TextureStore textureStore) + public Texture GetTextureFromPath(string path, TextureStore textureStore) { - Drawable drawable = null; - string storyboardPath = BeatmapInfo.BeatmapSet?.Files.FirstOrDefault(f => f.Filename.Equals(path, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); if (!string.IsNullOrEmpty(storyboardPath)) - drawable = new Sprite { Texture = textureStore.Get(storyboardPath) }; - // if the texture isn't available locally in the beatmap, some storyboards choose to source from the underlying skin lookup hierarchy. - else if (UseSkinSprites) - { - drawable = new SkinnableSprite(path) - { - RelativeSizeAxes = Axes.None, - AutoSizeAxes = Axes.Both, - }; - } + return textureStore.Get(storyboardPath); - return drawable; + return null; } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 6c40546325..a26c6f9be9 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -44,6 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer return new Room { Name = { Value = "test name" }, + Type = { Value = MatchType.HeadToHead }, Playlist = { new PlaylistItem(new TestBeatmap(Ruleset.Value).BeatmapInfo) diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index ff8e04cc58..8df44216b6 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -115,7 +115,9 @@ namespace osu.Game.Utils { mods = mods.ToArray(); - CheckCompatibleSet(mods, out invalidMods); + // exclude multi mods from compatibility checks. + // the loop below automatically marks all multi mods as not valid for gameplay anyway. + CheckCompatibleSet(mods.Where(m => !(m is MultiMod)), out invalidMods); foreach (var mod in mods) { diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 1bebf78d97..308ec7e7d6 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -29,15 +29,14 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index efd5bac38e..311c9ba345 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,8 +61,8 @@ - - + + @@ -84,7 +84,7 @@ - +