diff --git a/README.md b/README.md index efca075042..e09b4d86a5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # osu! [![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) -[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)]() +[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest) [![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) [![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs index 7308d6b499..8d8ee49af7 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs @@ -29,4 +29,3 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default } } } - diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs index d160956a6e..c8895f32f4 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs @@ -19,4 +19,3 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default } } } - diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs new file mode 100644 index 0000000000..60363aaeef --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.UI.Scrolling.Algorithms; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModConstantSpeed : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [Test] + public void TestConstantScroll() => CreateModTest(new ModTestData + { + Mod = new ManiaModConstantSpeed(), + PassCondition = () => + { + var hitObject = Player.ChildrenOfType().FirstOrDefault(); + return hitObject?.Dependencies.Get().Algorithm is ConstantScrollAlgorithm; + } + }); + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 59c766fd84..4c729fef83 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -238,6 +238,7 @@ namespace osu.Game.Rulesets.Mania new ManiaModMirror(), new ManiaModDifficultyAdjust(), new ManiaModInvert(), + new ManiaModConstantSpeed() }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs new file mode 100644 index 0000000000..078394b1d8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModConstantSpeed : Mod, IApplicableToDrawableRuleset + { + public override string Name => "Constant Speed"; + + public override string Acronym => "CS"; + + public override double ScoreMultiplier => 1; + + public override string Description => "No more tricky speed changes!"; + + public override IconUsage? Icon => FontAwesome.Solid.Equals; + + public override ModType Type => ModType.Conversion; + + public override bool Ranked => false; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + maniaRuleset.ScrollMethod = ScrollVisualisationMethod.Constant; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 941ac9816c..4ee060e91e 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -11,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; @@ -49,6 +51,22 @@ namespace osu.Game.Rulesets.Mania.UI protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; + public ScrollVisualisationMethod ScrollMethod + { + get => scrollMethod; + set + { + if (IsLoaded) + throw new InvalidOperationException($"Can't alter {nameof(ScrollMethod)} after ruleset is already loaded"); + + scrollMethod = value; + } + } + + private ScrollVisualisationMethod scrollMethod = ScrollVisualisationMethod.Sequential; + + protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod; + private readonly Bindable configDirection = new Bindable(); private readonly Bindable configTimeRange = new BindableDouble(); diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index c44ed69c4d..19e36a63f1 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -69,5 +69,9 @@ osu.Game + + + + \ No newline at end of file diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index ca68369ebb..67b2298f4c 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -45,6 +45,7 @@ + \ No newline at end of file diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs new file mode 100644 index 0000000000..7dcaabca3d --- /dev/null +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Utils; + +namespace osu.Game.Tests.Mods +{ + [TestFixture] + public class ModUtilsTest + { + [Test] + public void TestModIsCompatibleByItself() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object })); + } + + [Test] + public void TestIncompatibleThroughTopLevel() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + + mod1.Setup(m => m.IncompatibleMods).Returns(new[] { mod2.Object.GetType() }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False); + } + + [Test] + public void TestMultiModIncompatibleWithTopLevel() + { + var mod1 = new Mock(); + + // The nested mod. + var mod2 = new Mock(); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { mod1.Object.GetType() }); + + var multiMod = new MultiMod(new MultiMod(mod2.Object)); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod1.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, multiMod }), Is.False); + } + + [Test] + public void TestTopLevelIncompatibleWithMultiMod() + { + // The nested mod. + var mod1 = new Mock(); + var multiMod = new MultiMod(new MultiMod(mod1.Object)); + + var mod2 = new Mock(); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(CustomMod1) }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, multiMod }), Is.False); + } + + [Test] + public void TestCompatibleMods() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.True); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.True); + } + + [Test] + public void TestIncompatibleThroughBaseType() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(Mod) }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False); + } + + [Test] + public void TestAllowedThroughMostDerivedType() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { mod.Object.GetType() })); + } + + [Test] + public void TestNotAllowedThroughBaseType() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { typeof(Mod) }), Is.False); + } + + private static readonly object[] invalid_mod_test_scenarios = + { + // incompatible pair. + new object[] + { + new Mod[] { new OsuModDoubleTime(), new OsuModHalfTime() }, + new[] { typeof(OsuModDoubleTime), typeof(OsuModHalfTime) } + }, + // incompatible pair with derived class. + new object[] + { + new Mod[] { new OsuModNightcore(), new OsuModHalfTime() }, + new[] { typeof(OsuModNightcore), typeof(OsuModHalfTime) } + }, + // system mod. + new object[] + { + new Mod[] { new OsuModDoubleTime(), new OsuModTouchDevice() }, + new[] { typeof(OsuModTouchDevice) } + }, + // multi mod. + new object[] + { + new Mod[] { new MultiMod(new OsuModHalfTime()), new OsuModHalfTime() }, + new[] { typeof(MultiMod) } + }, + // valid pair. + new object[] + { + new Mod[] { new OsuModDoubleTime(), new OsuModHardRock() }, + null + } + }; + + [TestCaseSource(nameof(invalid_mod_test_scenarios))] + public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) + { + bool isValid = ModUtils.CheckValidForGameplay(inputMods, out var invalid); + + Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + + if (isValid) + Assert.IsNull(invalid); + else + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + } + + public abstract class CustomMod1 : Mod + { + } + + public abstract class CustomMod2 : Mod + { + } + } +} diff --git a/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs index eef9582af9..10216c3339 100644 --- a/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs +++ b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs @@ -3,8 +3,12 @@ using System; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Screens; using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Visual; @@ -58,5 +62,45 @@ namespace osu.Game.Tests.NonVisual AddStep("end operation", () => operation.Dispose()); AddAssert("operation is ended", () => !operationInProgress.Value); } + + [Test] + public void TestOperationDisposalAfterScreenExit() + { + TestScreenWithTracker screen = null; + OsuScreenStack stack; + IDisposable operation = null; + + AddStep("create screen with tracker", () => + { + Child = stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both + }; + + stack.Push(screen = new TestScreenWithTracker()); + }); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("begin operation", () => operation = screen.OngoingOperationTracker.BeginOperation()); + AddAssert("operation in progress", () => screen.OngoingOperationTracker.InProgress.Value); + + AddStep("dispose after screen exit", () => + { + screen.Exit(); + operation.Dispose(); + }); + AddAssert("operation ended", () => !screen.OngoingOperationTracker.InProgress.Value); + } + + private class TestScreenWithTracker : OsuScreen + { + public OngoingOperationTracker OngoingOperationTracker { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = OngoingOperationTracker = new OngoingOperationTracker(); + } + } } } diff --git a/osu.Game.Tests/Online/TestAPIModSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs similarity index 99% rename from osu.Game.Tests/Online/TestAPIModSerialization.cs rename to osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 5948582d77..aa6f66da81 100644 --- a/osu.Game.Tests/Online/TestAPIModSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -16,7 +16,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Online { [TestFixture] - public class TestAPIModSerialization + public class TestAPIModJsonSerialization { [Test] public void TestAcronymIsPreserved() diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs new file mode 100644 index 0000000000..4294f89397 --- /dev/null +++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using MessagePack; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + public class TestAPIModMessagePackSerialization + { + [Test] + public void TestAcronymIsPreserved() + { + var apiMod = new APIMod(new TestMod()); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + + Assert.That(deserialized.Acronym, Is.EqualTo(apiMod.Acronym)); + } + + [Test] + public void TestRawSettingIsPreserved() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + + Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(2.0)); + } + + [Test] + public void TestConvertedModHasCorrectSetting() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + var converted = (TestMod)deserialized.ToMod(new TestRuleset()); + + Assert.That(converted.TestSetting.Value, Is.EqualTo(2)); + } + + [Test] + public void TestDeserialiseTimeRampMod() + { + // Create the mod with values different from default. + var apiMod = new APIMod(new TestModTimeRamp + { + AdjustPitch = { Value = false }, + InitialRate = { Value = 1.25 }, + FinalRate = { Value = 0.25 } + }); + + var deserialised = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset()); + + Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false)); + Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25)); + Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => new Mod[] + { + new TestMod(), + new TestModTimeRamp(), + }; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new System.NotImplementedException(); + + public override string Description { get; } = string.Empty; + public override string ShortName { get; } = string.Empty; + } + + private class TestMod : Mod + { + public override string Name => "Test Mod"; + public override string Acronym => "TM"; + public override double ScoreMultiplier => 1; + + [SettingSource("Test")] + public BindableNumber TestSetting { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Precision = 0.01, + }; + } + + private class TestModTimeRamp : ModTimeRamp + { + public override string Name => "Test Mod"; + public override string Acronym => "TMTR"; + public override double ScoreMultiplier => 1; + + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber InitialRate { get; } = new BindableDouble + { + MinValue = 1, + MaxValue = 2, + Default = 1.5, + Value = 1.5, + Precision = 0.01, + }; + + [SettingSource("Final rate", "The speed increase to ramp towards")] + public override BindableNumber FinalRate { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 1, + Default = 0.5, + Value = 0.5, + Precision = 0.01, + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public override BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs new file mode 100644 index 0000000000..d3475de157 --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -0,0 +1,188 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene + { + private RulesetStore rulesets; + private TestBeatmapManager beatmaps; + + private string testBeatmapFile; + private BeatmapInfo testBeatmapInfo; + private BeatmapSetInfo testBeatmapSet; + + private readonly Bindable selectedItem = new Bindable(); + private OnlinePlayBeatmapAvailablilityTracker availablilityTracker; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, ContextFactory, rulesets, API, audio, host, Beatmap.Default)); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + beatmaps.AllowImport = new TaskCompletionSource(); + + testBeatmapFile = TestResources.GetTestBeatmapForImport(); + + testBeatmapInfo = getTestBeatmapInfo(testBeatmapFile); + testBeatmapSet = testBeatmapInfo.BeatmapSet; + + var existing = beatmaps.QueryBeatmapSet(s => s.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID); + if (existing != null) + beatmaps.Delete(existing); + + selectedItem.Value = new PlaylistItem + { + Beatmap = { Value = testBeatmapInfo }, + Ruleset = { Value = testBeatmapInfo.Ruleset }, + }; + + Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = selectedItem, } + }; + }); + + [Test] + public void TestBeatmapDownloadingFlow() + { + AddAssert("ensure beatmap unavailable", () => !beatmaps.IsAvailableLocally(testBeatmapSet)); + addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); + + AddStep("start downloading", () => beatmaps.Download(testBeatmapSet)); + addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f)); + + AddStep("set progress 40%", () => ((TestDownloadRequest)beatmaps.GetExistingDownload(testBeatmapSet)).SetProgress(0.4f)); + addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f)); + + AddStep("finish download", () => ((TestDownloadRequest)beatmaps.GetExistingDownload(testBeatmapSet)).TriggerSuccess(testBeatmapFile)); + addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing); + + AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddUntilStep("wait for import", () => beatmaps.CurrentImportTask?.IsCompleted == true); + addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); + } + + [Test] + public void TestTrackerRespectsSoftDeleting() + { + AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).Wait()); + addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); + + AddStep("delete beatmap", () => beatmaps.Delete(beatmaps.QueryBeatmapSet(b => b.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID))); + addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); + + AddStep("undelete beatmap", () => beatmaps.Undelete(beatmaps.QueryBeatmapSet(b => b.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID))); + addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); + } + + [Test] + public void TestTrackerRespectsChecksum() + { + AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + + AddStep("import altered beatmap", () => + { + beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + }); + addAvailabilityCheckStep("state still not downloaded", BeatmapAvailability.NotDownloaded); + + AddStep("recreate tracker", () => Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = selectedItem } + }); + addAvailabilityCheckStep("state not downloaded as well", BeatmapAvailability.NotDownloaded); + } + + private void addAvailabilityCheckStep(string description, Func expected) + { + AddAssert(description, () => availablilityTracker.Availability.Value.Equals(expected.Invoke())); + } + + private static BeatmapInfo getTestBeatmapInfo(string archiveFile) + { + BeatmapInfo info; + + using (var archive = new ZipArchiveReader(File.OpenRead(archiveFile))) + using (var stream = archive.GetStream("Soleily - Renatus (Gamu) [Insane].osu")) + using (var reader = new LineBufferedReader(stream)) + { + var decoder = Decoder.GetDecoder(reader); + var beatmap = decoder.Decode(reader); + + info = beatmap.BeatmapInfo; + info.BeatmapSet.Beatmaps = new List { info }; + info.BeatmapSet.Metadata = info.Metadata; + info.MD5Hash = stream.ComputeMD5Hash(); + info.Hash = stream.ComputeSHA2Hash(); + } + + return info; + } + + private class TestBeatmapManager : BeatmapManager + { + public TaskCompletionSource AllowImport = new TaskCompletionSource(); + + public Task CurrentImportTask { get; private set; } + + protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) + => new TestDownloadRequest(set); + + public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) + : base(storage, contextFactory, rulesets, api, audioManager, host, defaultBeatmap, performOnlineLookups) + { + } + + public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, CancellationToken cancellationToken = default) + { + await AllowImport.Task; + return await (CurrentImportTask = base.Import(item, archive, cancellationToken)); + } + } + + private class TestDownloadRequest : ArchiveDownloadRequest + { + public new void SetProgress(float progress) => base.SetProgress(progress); + public new void TriggerSuccess(string filename) => base.TriggerSuccess(filename); + + public TestDownloadRequest(BeatmapSetInfo model) + : base(model) + { + } + + protected override string Target => null; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 874c1694eb..960aad10c6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -11,8 +11,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -20,6 +20,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Beatmaps; +using osu.Game.Users; using osuTK; using osuTK.Input; @@ -241,7 +242,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } private void moveToItem(int index, Vector2? offset = null) - => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType>().ElementAt(index), offset)); + => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().ElementAt(index), offset)); private void moveToDragger(int index, Vector2? offset = null) => AddStep($"move mouse to dragger {index}", () => { @@ -252,7 +253,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () => { var item = playlist.ChildrenOfType>().ElementAt(index); - InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); + InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); }); private void assertHandleVisibility(int index, bool visible) @@ -260,7 +261,7 @@ namespace osu.Game.Tests.Visual.Multiplayer () => (playlist.ChildrenOfType.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible); private void assertDeleteButtonVisibility(int index, bool visible) - => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); + => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); private void createPlaylist(bool allowEdit, bool allowSelection) { @@ -278,7 +279,21 @@ namespace osu.Game.Tests.Visual.Multiplayer playlist.Items.Add(new PlaylistItem { ID = i, - Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Beatmap = + { + Value = i % 2 == 1 + ? new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo + : new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "Artist", + Author = new User { Username = "Creator name here" }, + Title = "Long title used to check background colour", + }, + BeatmapSet = new BeatmapSetInfo() + } + }, Ruleset = { Value = new OsuRuleset().RulesetInfo }, RequiredMods = { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs new file mode 100644 index 0000000000..26a0301d8a --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.OnlinePlay; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneFreeModSelectOverlay : MultiplayerTestScene + { + [SetUp] + public new void Setup() => Schedule(() => + { + Child = new FreeModSelectOverlay + { + State = { Value = Visibility.Visible } + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 968a869532..e2c98c0aad 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -8,6 +8,8 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Users; using osuTK; @@ -123,5 +125,32 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); } + + [Test] + public void TestUserWithMods() + { + AddStep("add user", () => + { + Client.AddUser(new User + { + Id = 0, + Username = "User 0", + CurrentModeRank = RNG.Next(1, 100000), + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + + Client.ChangeUserMods(0, new Mod[] + { + new OsuModHardRock(), + new OsuModDifficultyAdjust { ApproachRate = { Value = 1 } } + }); + }); + + for (var i = MultiplayerUserState.Idle; i < MultiplayerUserState.Results; i++) + { + var state = i; + AddStep($"set state: {state}", () => Client.ChangeUserState(0, state)); + } + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 878776bf51..3b3b1bee86 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -6,6 +6,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Testing; @@ -26,8 +27,11 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerReadyButton : MultiplayerTestScene { private MultiplayerReadyButton button; + private OnlinePlayBeatmapAvailablilityTracker beatmapTracker; private BeatmapSetInfo importedSet; + private readonly Bindable selectedItem = new Bindable(); + private BeatmapManager beatmaps; private RulesetStore rulesets; @@ -39,6 +43,13 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + + Add(beatmapTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = selectedItem } + }); + + Dependencies.Cache(beatmapTracker); } [SetUp] @@ -46,20 +57,20 @@ namespace osu.Game.Tests.Visual.Multiplayer { importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + selectedItem.Value = new PlaylistItem + { + Beatmap = { Value = Beatmap.Value.BeatmapInfo }, + Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset }, + }; - Child = button = new MultiplayerReadyButton + if (button != null) + Remove(button); + + Add(button = new MultiplayerReadyButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), - SelectedItem = - { - Value = new PlaylistItem - { - Beatmap = { Value = Beatmap.Value.BeatmapInfo }, - Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset } - } - }, OnReadyClick = async () => { readyClickOperation = OngoingOperationTracker.BeginOperation(); @@ -73,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer await Client.ToggleReady(); readyClickOperation.Dispose(); } - }; + }); }); [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs similarity index 96% rename from osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs rename to osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index e0fd7d9874..2f7e59f800 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -19,11 +19,11 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Screens.Select; +using osu.Game.Screens.OnlinePlay.Playlists; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchSongSelect : RoomTestScene + public class TestScenePlaylistsSongSelect : RoomTestScene { [Resolved] private BeatmapManager beatmapManager { get; set; } @@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private RulesetStore rulesets; - private TestMatchSongSelect songSelect; + private TestPlaylistsSongSelect songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -89,7 +89,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Beatmap.SetDefault(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestMatchSongSelect())); + AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect())); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); } @@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("item has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)Room.Playlist.First().RequiredMods[0]).SpeedChange.Value)); } - private class TestMatchSongSelect : MatchSongSelect + private class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 492abdd88d..01e67b1681 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -8,16 +8,16 @@ using osu.Game.Users; using osuTK; using System; using System.Linq; +using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Overlays.Chat; +using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneStandAloneChatDisplay : OsuTestScene + public class TestSceneStandAloneChatDisplay : OsuManualInputManagerTestScene { - private readonly Channel testChannel = new Channel(); - private readonly User admin = new User { Username = "HappyStick", @@ -46,92 +46,97 @@ namespace osu.Game.Tests.Visual.Online [Cached] private ChannelManager channelManager = new ChannelManager(); - private readonly TestStandAloneChatDisplay chatDisplay; - private readonly TestStandAloneChatDisplay chatDisplay2; + private TestStandAloneChatDisplay chatDisplay; + private int messageIdSequence; + + private Channel testChannel; public TestSceneStandAloneChatDisplay() { Add(channelManager); - - Add(chatDisplay = new TestStandAloneChatDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding(20), - Size = new Vector2(400, 80) - }); - - Add(chatDisplay2 = new TestStandAloneChatDisplay(true) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding(20), - Size = new Vector2(400, 150) - }); } - protected override void LoadComplete() + [SetUp] + public void SetUp() => Schedule(() => { - base.LoadComplete(); + messageIdSequence = 0; + channelManager.CurrentChannel.Value = testChannel = new Channel(); - channelManager.CurrentChannel.Value = testChannel; + Children = new[] + { + chatDisplay = new TestStandAloneChatDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding(20), + Size = new Vector2(400, 80), + Channel = { Value = testChannel }, + }, + new TestStandAloneChatDisplay(true) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding(20), + Size = new Vector2(400, 150), + Channel = { Value = testChannel }, + } + }; + }); - chatDisplay.Channel.Value = testChannel; - chatDisplay2.Channel.Value = testChannel; - - int sequence = 0; - - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(sequence++) + [Test] + public void TestManyMessages() + { + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = admin, Content = "I am a wang!" })); - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = redUser, Content = "I am team red." })); - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = redUser, Content = "I plan to win!" })); - AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = blueUser, Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." })); - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = admin, Content = "Okay okay, calm down guys. Let's do this!" })); - AddStep("message from long username", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = longUsernameUser, Content = "Hi guys, my new username is lit!" })); - AddStep("message with new date", () => testChannel.AddNewMessages(new Message(sequence++) + AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = longUsernameUser, Content = "Message from the future!", Timestamp = DateTimeOffset.Now })); - AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom); + checkScrolledToBottom(); const int messages_per_call = 10; AddRepeatStep("add many messages", () => { for (int i = 0; i < messages_per_call; i++) { - testChannel.AddNewMessages(new Message(sequence++) + testChannel.AddNewMessages(new Message(messageIdSequence++) { Sender = longUsernameUser, Content = "Many messages! " + Guid.NewGuid(), @@ -153,9 +158,133 @@ namespace osu.Game.Tests.Visual.Online return true; }); - AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom); + checkScrolledToBottom(); } + /// + /// Tests that when a message gets wrapped by the chat display getting contracted while scrolled to bottom, the chat will still keep scrolling down. + /// + [Test] + public void TestMessageWrappingKeepsAutoScrolling() + { + fillChat(); + + // send message with short words for text wrapping to occur when contracting chat. + sendMessage(); + + AddStep("contract chat", () => chatDisplay.Width -= 100); + checkScrolledToBottom(); + + AddStep("send another message", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = admin, + Content = "As we were saying...", + })); + + checkScrolledToBottom(); + } + + [Test] + public void TestUserScrollOverride() + { + fillChat(); + + sendMessage(); + checkScrolledToBottom(); + + AddStep("User scroll up", () => + { + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre + new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + checkNotScrolledToBottom(); + sendMessage(); + checkNotScrolledToBottom(); + + AddRepeatStep("User scroll to bottom", () => + { + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre - new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height)); + InputManager.ReleaseButton(MouseButton.Left); + }, 5); + + checkScrolledToBottom(); + sendMessage(); + checkScrolledToBottom(); + } + + [Test] + public void TestLocalEchoMessageResetsScroll() + { + fillChat(); + + sendMessage(); + checkScrolledToBottom(); + + AddStep("User scroll up", () => + { + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre + new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + checkNotScrolledToBottom(); + sendMessage(); + checkNotScrolledToBottom(); + + sendLocalMessage(); + checkScrolledToBottom(); + + sendMessage(); + checkScrolledToBottom(); + } + + private void fillChat() + { + AddStep("fill chat", () => + { + for (int i = 0; i < 10; i++) + { + testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = $"some stuff {Guid.NewGuid()}", + }); + } + }); + + checkScrolledToBottom(); + } + + private void sendMessage() + { + AddStep("send lorem ipsum", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce et bibendum velit.", + })); + } + + private void sendLocalMessage() + { + AddStep("send local echo", () => testChannel.AddLocalEcho(new LocalEchoMessage + { + Sender = longUsernameUser, + Content = "This is a local echo message.", + })); + } + + private void checkScrolledToBottom() => + AddUntilStep("is scrolled to bottom", () => chatDisplay.ScrolledToBottom); + + private void checkNotScrolledToBottom() => + AddUntilStep("not scrolled to bottom", () => !chatDisplay.ScrolledToBottom); + private class TestStandAloneChatDisplay : StandAloneChatDisplay { public TestStandAloneChatDisplay(bool textbox = false) @@ -165,7 +294,7 @@ namespace osu.Game.Tests.Visual.Online protected DrawableChannel DrawableChannel => InternalChildren.OfType().First(); - protected OsuScrollContainer ScrollContainer => (OsuScrollContainer)((Container)DrawableChannel.Child).Child; + protected UserTrackingScrollContainer ScrollContainer => (UserTrackingScrollContainer)((Container)DrawableChannel.Child).Child; public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child; diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 008c862cc3..618447eae2 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -4,7 +4,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics.Containers; @@ -28,12 +27,7 @@ namespace osu.Game.Tests.Visual.Playlists { base.SetUpSteps(); - AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - })); + AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen())); AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs new file mode 100644 index 0000000000..e7fa7d9235 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModIcon : OsuTestScene + { + [Test] + public void TestChangeModType() + { + ModIcon icon = null; + + AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); + AddStep("change mod", () => icon.Mod = new OsuModEasy()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index bd4010a7f3..44605f4994 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -38,28 +39,7 @@ namespace osu.Game.Tests.Visual.UserInterface } [SetUp] - public void SetUp() => Schedule(() => - { - SelectedMods.Value = Array.Empty(); - Children = new Drawable[] - { - modSelect = new TestModSelectOverlay - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - SelectedMods = { BindTarget = SelectedMods } - }, - - modDisplay = new ModDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Position = new Vector2(-5, 25), - Current = { BindTarget = modSelect.SelectedMods } - } - }; - }); + public void SetUp() => Schedule(() => createDisplay(() => new TestModSelectOverlay())); [SetUpSteps] public void SetUpSteps() @@ -67,6 +47,32 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("show", () => modSelect.Show()); } + [Test] + public void TestAnimationFlushOnClose() + { + changeRuleset(0); + + AddStep("Select all fun mods", () => + { + modSelect.ModSectionsContainer + .Single(c => c.ModType == ModType.DifficultyIncrease) + .SelectAll(); + }); + + AddUntilStep("many mods selected", () => modDisplay.Current.Value.Count >= 5); + + AddStep("trigger deselect and close overlay", () => + { + modSelect.ModSectionsContainer + .Single(c => c.ModType == ModType.DifficultyIncrease) + .DeselectAll(); + + modSelect.Hide(); + }); + + AddAssert("all mods deselected", () => modDisplay.Current.Value.Count == 0); + } + [Test] public void TestOsuMods() { @@ -146,6 +152,46 @@ namespace osu.Game.Tests.Visual.UserInterface }); } + [Test] + public void TestNonStacked() + { + changeRuleset(0); + + AddStep("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay())); + + AddStep("show", () => modSelect.Show()); + + AddAssert("ensure all buttons are spread out", () => modSelect.ChildrenOfType().All(m => m.Mods.Length <= 1)); + } + + [Test] + public void TestChangeIsValidChangesButtonVisibility() + { + changeRuleset(0); + + AddAssert("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); + + AddStep("make double time invalid", () => modSelect.IsValidMod = m => !(m is OsuModDoubleTime)); + AddUntilStep("double time not visible", () => modSelect.ChildrenOfType().All(b => !b.Mods.Any(m => m is OsuModDoubleTime))); + AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore))); + + AddStep("make double time valid again", () => modSelect.IsValidMod = m => true); + AddUntilStep("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); + AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore))); + } + + [Test] + public void TestChangeIsValidPreservesSelection() + { + changeRuleset(0); + + AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); + AddAssert("DT + HD selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); + + AddStep("make NF invalid", () => modSelect.IsValidMod = m => !(m is ModNoFail)); + AddAssert("DT + HD still selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); + } + private void testSingleMod(Mod mod) { selectNext(mod); @@ -265,12 +311,37 @@ namespace osu.Game.Tests.Visual.UserInterface private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour()); - private class TestModSelectOverlay : ModSelectOverlay + private void createDisplay(Func createOverlayFunc) + { + SelectedMods.Value = Array.Empty(); + Children = new Drawable[] + { + modSelect = createOverlayFunc().With(d => + { + d.Origin = Anchor.BottomCentre; + d.Anchor = Anchor.BottomCentre; + d.SelectedMods.BindTarget = SelectedMods; + }), + modDisplay = new ModDisplay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Position = new Vector2(-5, 25), + Current = { BindTarget = modSelect.SelectedMods } + } + }; + } + + private class TestModSelectOverlay : SoloModSelectOverlay { public new Bindable> SelectedMods => base.SelectedMods; public bool AllLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); + public new FillFlowContainer ModSectionsContainer => + base.ModSectionsContainer; + public ModButton GetModButton(Mod mod) { var section = ModSectionsContainer.Children.Single(s => s.ModType == mod.Type); @@ -283,5 +354,10 @@ namespace osu.Game.Tests.Visual.UserInterface public new Color4 LowMultiplierColour => base.LowMultiplierColour; public new Color4 HighMultiplierColour => base.HighMultiplierColour; } + + private class TestNonStackedModSelectOverlay : TestModSelectOverlay + { + protected override bool Stacked => false; + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index 8614700b15..3c889bdec4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded); } - private class TestModSelectOverlay : ModSelectOverlay + private class TestModSelectOverlay : SoloModSelectOverlay { public new VisibilityContainer ModSettingsContainer => base.ModSettingsContainer; public new TriangleButton CustomiseButton => base.CustomiseButton; diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index c0c0578391..d29ed94b5f 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -7,6 +7,7 @@ + WinExe diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 9f69ad035f..9581edfc8d 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -308,7 +308,7 @@ namespace osu.Game.Database /// The model to be imported. /// An optional archive to use for model population. /// An optional cancellation token. - public async Task Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => + public virtual async Task Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => { cancellationToken.ThrowIfCancellationRequested(); diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index b8ce34b204..17506ce0f5 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -25,6 +25,8 @@ namespace osu.Game.Graphics.Containers /// public bool UserScrolling { get; private set; } + public void CancelUserScroll() => UserScrolling = false; + public UserTrackingScrollContainer() { } @@ -45,5 +47,11 @@ namespace osu.Game.Graphics.Containers UserScrolling = false; base.ScrollTo(value, animated, distanceDecay); } + + public new void ScrollToEnd(bool animated = true, bool allowDuringDrag = false) + { + UserScrolling = false; + base.ScrollToEnd(animated, allowDuringDrag); + } } } diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index 5168ff646b..7a8db158c1 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -4,54 +4,38 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Online; using osuTK; namespace osu.Game.Graphics.UserInterface { - public class DownloadButton : OsuAnimatedButton + public class DownloadButton : GrayButton { - public readonly Bindable State = new Bindable(); - - private readonly SpriteIcon icon; - private readonly SpriteIcon checkmark; - private readonly Box background; - [Resolved] private OsuColour colours { get; set; } + public readonly Bindable State = new Bindable(); + + private SpriteIcon checkmark; + public DownloadButton() + : base(FontAwesome.Solid.Download) { - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Depth = float.MaxValue - }, - icon = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(13), - Icon = FontAwesome.Solid.Download, - }, - checkmark = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - X = 8, - Size = Vector2.Zero, - Icon = FontAwesome.Solid.Check, - } - }; } [BackgroundDependencyLoader] private void load() { + AddInternal(checkmark = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = 8, + Size = Vector2.Zero, + Icon = FontAwesome.Solid.Check, + }); + State.BindValueChanged(updateState, true); } @@ -60,27 +44,27 @@ namespace osu.Game.Graphics.UserInterface switch (state.NewValue) { case DownloadState.NotDownloaded: - background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); - icon.MoveToX(0, 500, Easing.InOutExpo); + Background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); + Icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); TooltipText = "Download"; break; case DownloadState.Downloading: - background.FadeColour(colours.Blue, 500, Easing.InOutExpo); - icon.MoveToX(0, 500, Easing.InOutExpo); + Background.FadeColour(colours.Blue, 500, Easing.InOutExpo); + Icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); TooltipText = "Downloading..."; break; case DownloadState.Importing: - background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); + Background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); TooltipText = "Importing"; break; case DownloadState.LocallyAvailable: - background.FadeColour(colours.Green, 500, Easing.InOutExpo); - icon.MoveToX(-8, 500, Easing.InOutExpo); + Background.FadeColour(colours.Green, 500, Easing.InOutExpo); + Icon.MoveToX(-8, 500, Easing.InOutExpo); checkmark.ScaleTo(new Vector2(13), 500, Easing.InOutExpo); break; } diff --git a/osu.Game/Graphics/UserInterface/GrayButton.cs b/osu.Game/Graphics/UserInterface/GrayButton.cs new file mode 100644 index 0000000000..88c46f29e0 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/GrayButton.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public class GrayButton : OsuAnimatedButton + { + protected SpriteIcon Icon { get; private set; } + protected Box Background { get; private set; } + + private readonly IconUsage icon; + + [Resolved] + private OsuColour colours { get; set; } + + public GrayButton(IconUsage icon) + { + this.icon = icon; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + Background = new Box + { + Colour = colours.Gray4, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }, + Icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(13), + Icon = icon, + }, + }; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs index 82b09e0821..18d8b880ea 100644 --- a/osu.Game/Graphics/UserInterface/Nub.cs +++ b/osu.Game/Graphics/UserInterface/Nub.cs @@ -42,13 +42,7 @@ namespace osu.Game.Graphics.UserInterface }, }; - Current.ValueChanged += filled => - { - if (filled.NewValue) - fill.FadeIn(200, Easing.OutQuint); - else - fill.FadeTo(0.01f, 200, Easing.OutQuint); //todo: remove once we figure why containers aren't drawing at all times - }; + Current.ValueChanged += filled => fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); } [BackgroundDependencyLoader] diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index 6593531099..f6effa0834 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; @@ -18,6 +19,11 @@ namespace osu.Game.Graphics.UserInterface public Color4 UncheckedColor { get; set; } = Color4.White; public int FadeDuration { get; set; } + /// + /// Whether to play sounds when the state changes as a result of user interaction. + /// + protected virtual bool PlaySoundsOnUserChange => true; + public string LabelText { set @@ -43,7 +49,7 @@ namespace osu.Game.Graphics.UserInterface private SampleChannel sampleChecked; private SampleChannel sampleUnchecked; - public OsuCheckbox() + public OsuCheckbox(bool nubOnRight = true) { AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; @@ -52,26 +58,42 @@ namespace osu.Game.Graphics.UserInterface Children = new Drawable[] { - labelText = new OsuTextFlowContainer + labelText = new OsuTextFlowContainer(ApplyLabelParameters) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding } - }, - Nub = new Nub - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding { Right = nub_padding }, }, + Nub = new Nub(), new HoverClickSounds() }; + if (nubOnRight) + { + Nub.Anchor = Anchor.CentreRight; + Nub.Origin = Anchor.CentreRight; + Nub.Margin = new MarginPadding { Right = nub_padding }; + labelText.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 }; + } + else + { + Nub.Anchor = Anchor.CentreLeft; + Nub.Origin = Anchor.CentreLeft; + Nub.Margin = new MarginPadding { Left = nub_padding }; + labelText.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 }; + } + Nub.Current.BindTo(Current); Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1; } + /// + /// A function which can be overridden to change the parameters of the label's text. + /// + protected virtual void ApplyLabelParameters(SpriteText text) + { + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -96,10 +118,14 @@ namespace osu.Game.Graphics.UserInterface protected override void OnUserChange(bool value) { base.OnUserChange(value); - if (value) - sampleChecked?.Play(); - else - sampleUnchecked?.Play(); + + if (PlaySoundsOnUserChange) + { + if (value) + sampleChecked?.Play(); + else + sampleUnchecked?.Play(); + } } } } diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs index 940b9b4803..62e22d8f88 100644 --- a/osu.Game/Online/API/APIDownloadRequest.cs +++ b/osu.Game/Online/API/APIDownloadRequest.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.IO; using osu.Framework.IO.Network; @@ -28,13 +29,19 @@ namespace osu.Game.Online.API private void request_Progress(long current, long total) => API.Schedule(() => Progressed?.Invoke(current, total)); - protected APIDownloadRequest() + protected void TriggerSuccess(string filename) { - base.Success += onSuccess; + if (this.filename != null) + throw new InvalidOperationException("Attempted to trigger success more than once"); + + this.filename = filename; + + TriggerSuccess(); } - private void onSuccess() + internal override void TriggerSuccess() { + base.TriggerSuccess(); Success?.Invoke(filename); } diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index 69ce3825ee..bff08b0515 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -23,6 +23,7 @@ namespace osu.Game.Online.API [JsonProperty("settings")] [Key(1)] + [MessagePackFormatter(typeof(ModSettingsDictionaryFormatter))] public Dictionary Settings { get; set; } = new Dictionary(); [JsonConstructor] diff --git a/osu.Game/Online/API/ArchiveDownloadRequest.cs b/osu.Game/Online/API/ArchiveDownloadRequest.cs index f1966aeb2b..0bf238109e 100644 --- a/osu.Game/Online/API/ArchiveDownloadRequest.cs +++ b/osu.Game/Online/API/ArchiveDownloadRequest.cs @@ -10,7 +10,7 @@ namespace osu.Game.Online.API { public readonly TModel Model; - public float Progress; + public float Progress { get; private set; } public event Action DownloadProgressed; @@ -18,7 +18,13 @@ namespace osu.Game.Online.API { Model = model; - Progressed += (current, total) => DownloadProgressed?.Invoke(Progress = (float)current / total); + Progressed += (current, total) => SetProgress((float)current / total); + } + + protected void SetProgress(float progress) + { + Progress = progress; + DownloadProgressed?.Invoke(progress); } } } diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs new file mode 100644 index 0000000000..99e87677fa --- /dev/null +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using MessagePack; +using MessagePack.Formatters; +using osu.Framework.Bindables; + +namespace osu.Game.Online.API +{ + public class ModSettingsDictionaryFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, Dictionary value, MessagePackSerializerOptions options) + { + var primitiveFormatter = PrimitiveObjectFormatter.Instance; + + writer.WriteArrayHeader(value.Count); + + foreach (var kvp in value) + { + var stringBytes = new ReadOnlySequence(Encoding.UTF8.GetBytes(kvp.Key)); + writer.WriteString(in stringBytes); + + switch (kvp.Value) + { + case Bindable d: + primitiveFormatter.Serialize(ref writer, d.Value, options); + break; + + case Bindable i: + primitiveFormatter.Serialize(ref writer, i.Value, options); + break; + + case Bindable f: + primitiveFormatter.Serialize(ref writer, f.Value, options); + break; + + case Bindable b: + primitiveFormatter.Serialize(ref writer, b.Value, options); + break; + + default: + // fall back for non-bindable cases. + primitiveFormatter.Serialize(ref writer, kvp.Value, options); + break; + } + } + } + + public Dictionary Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var output = new Dictionary(); + + int itemCount = reader.ReadArrayHeader(); + + for (int i = 0; i < itemCount; i++) + { + output[reader.ReadString()] = + PrimitiveObjectFormatter.Instance.Deserialize(ref reader, options); + } + + return output; + } + } +} diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index 7a64c9002d..52042c266b 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -20,7 +21,7 @@ namespace osu.Game.Online protected readonly Bindable Model = new Bindable(); [Resolved(CanBeNull = true)] - private TModelManager manager { get; set; } + protected TModelManager Manager { get; private set; } /// /// Holds the current download state of the , whether is has already been downloaded, is in progress, or is not downloaded. @@ -46,25 +47,41 @@ namespace osu.Game.Online { if (modelInfo.NewValue == null) attachDownload(null); - else if (manager?.IsAvailableLocally(modelInfo.NewValue) == true) + else if (IsModelAvailableLocally()) State.Value = DownloadState.LocallyAvailable; else - attachDownload(manager?.GetExistingDownload(modelInfo.NewValue)); + attachDownload(Manager?.GetExistingDownload(modelInfo.NewValue)); }, true); - if (manager == null) + if (Manager == null) return; - managerDownloadBegan = manager.DownloadBegan.GetBoundCopy(); + managerDownloadBegan = Manager.DownloadBegan.GetBoundCopy(); managerDownloadBegan.BindValueChanged(downloadBegan); - managerDownloadFailed = manager.DownloadFailed.GetBoundCopy(); + managerDownloadFailed = Manager.DownloadFailed.GetBoundCopy(); managerDownloadFailed.BindValueChanged(downloadFailed); - managedUpdated = manager.ItemUpdated.GetBoundCopy(); + managedUpdated = Manager.ItemUpdated.GetBoundCopy(); managedUpdated.BindValueChanged(itemUpdated); - managerRemoved = manager.ItemRemoved.GetBoundCopy(); + managerRemoved = Manager.ItemRemoved.GetBoundCopy(); managerRemoved.BindValueChanged(itemRemoved); } + /// + /// Checks that a database model matches the one expected to be downloaded. + /// + /// + /// For online play, this could be used to check that the databased model matches the online beatmap. + /// + /// The model in database. + protected virtual bool VerifyDatabasedModel([NotNull] TModel databasedModel) => true; + + /// + /// Whether the given model is available in the database. + /// By default, this calls , + /// but can be overriden to add additional checks for verifying the model in database. + /// + protected virtual bool IsModelAvailableLocally() => Manager?.IsAvailableLocally(Model.Value) == true; + private void downloadBegan(ValueChangedEvent>> weakRequest) { if (weakRequest.NewValue.TryGetTarget(out var request)) @@ -134,23 +151,35 @@ namespace osu.Game.Online private void itemUpdated(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) - setDownloadStateFromManager(item, DownloadState.LocallyAvailable); + { + Schedule(() => + { + if (!item.Equals(Model.Value)) + return; + + if (!VerifyDatabasedModel(item)) + { + State.Value = DownloadState.NotDownloaded; + return; + } + + State.Value = DownloadState.LocallyAvailable; + }); + } } private void itemRemoved(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) - setDownloadStateFromManager(item, DownloadState.NotDownloaded); + { + Schedule(() => + { + if (item.Equals(Model.Value)) + State.Value = DownloadState.NotDownloaded; + }); + } } - private void setDownloadStateFromManager(TModel s, DownloadState state) => Schedule(() => - { - if (!s.Equals(Model.Value)) - return; - - State.Value = state; - }); - #region Disposal protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index 19dd473230..6d7b9d24d6 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Threading.Tasks; +using osu.Game.Online.API; using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer @@ -55,6 +57,13 @@ namespace osu.Game.Online.Multiplayer /// The new beatmap availability state of the user. Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability); + /// + /// Signals that a user in this room changed their local mods. + /// + /// The ID of the user whose mods have changed. + /// The user's new local mods. + Task UserModsChanged(int userId, IEnumerable mods); + /// /// Signals that a match is to be started. This will *only* be sent to clients which are to begin loading at this point. /// diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 09816974a7..3527ce6314 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Threading.Tasks; +using osu.Game.Online.API; using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer @@ -47,6 +49,12 @@ namespace osu.Game.Online.Multiplayer /// The proposed new beatmap availability state. Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + /// + /// Change the local user's mods in the currently joined room. + /// + /// The proposed new mods, excluding any required by the room itself. + Task ChangeUserMods(IEnumerable newMods); + /// /// As the host of a room, start the match. /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index b13d4fa899..572b3bbdf2 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -4,6 +4,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -91,6 +92,7 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); connection.Closed += async ex => { @@ -189,6 +191,14 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); } + public override Task ChangeUserMods(IEnumerable newMods) + { + if (!isConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); + } + public override Task StartMatch() { if (!isConnected.Value) diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index 0ead5db84c..4fb9d724b5 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -30,15 +30,24 @@ namespace osu.Game.Online.Multiplayer [NotNull] [Key(4)] - public IEnumerable Mods { get; set; } = Enumerable.Empty(); + public IEnumerable RequiredMods { get; set; } = Enumerable.Empty(); + + [NotNull] + [Key(5)] + public IEnumerable AllowedMods { get; set; } = Enumerable.Empty(); public bool Equals(MultiplayerRoomSettings other) => BeatmapID == other.BeatmapID && BeatmapChecksum == other.BeatmapChecksum - && Mods.SequenceEqual(other.Mods) + && RequiredMods.SequenceEqual(other.RequiredMods) + && AllowedMods.SequenceEqual(other.AllowedMods) && RulesetID == other.RulesetID && Name.Equals(other.Name, StringComparison.Ordinal); - public override string ToString() => $"Name:{Name} Beatmap:{BeatmapID} ({BeatmapChecksum}) Mods:{string.Join(',', Mods)} Ruleset:{RulesetID}"; + public override string ToString() => $"Name:{Name}" + + $" Beatmap:{BeatmapID} ({BeatmapChecksum})" + + $" RequiredMods:{string.Join(',', RequiredMods)}" + + $" AllowedMods:{string.Join(',', AllowedMods)}" + + $" Ruleset:{RulesetID}"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index b300be9f60..c654127b94 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -4,8 +4,12 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using MessagePack; using Newtonsoft.Json; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Users; @@ -27,6 +31,13 @@ namespace osu.Game.Online.Multiplayer [Key(2)] public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.LocallyAvailable(); + /// + /// Any mods applicable only to the local user. + /// + [Key(3)] + [NotNull] + public IEnumerable Mods { get; set; } = Enumerable.Empty(); + [IgnoreMember] public User? User { get; set; } diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs index 48194d1f0f..f7c9193dfe 100644 --- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs @@ -21,6 +21,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Users; using osu.Game.Utils; @@ -191,7 +192,8 @@ namespace osu.Game.Online.Multiplayer BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, RulesetID = item.GetOr(existingPlaylistItem).RulesetID, - Mods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods + RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, + AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods }); } @@ -229,6 +231,14 @@ namespace osu.Game.Online.Multiplayer public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + /// + /// Change the local user's mods in the currently joined room. + /// + /// The proposed new mods, excluding any required by the room itself. + public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList()); + + public abstract Task ChangeUserMods(IEnumerable newMods); + public abstract Task StartMatch(); Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) @@ -377,6 +387,27 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + public Task UserModsChanged(int userId, IEnumerable mods) + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - user mods are mostly for display. + if (user == null) + return; + + user.Mods = mods; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; + } + Task IMultiplayerClient.LoadRequested() { if (Room == null) @@ -500,7 +531,8 @@ namespace osu.Game.Online.Multiplayer beatmap.MD5Hash = settings.BeatmapChecksum; var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance(); - var mods = settings.Mods.Select(m => m.ToMod(ruleset)); + var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); + var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); PlaylistItem playlistItem = new PlaylistItem { @@ -510,6 +542,7 @@ namespace osu.Game.Online.Multiplayer }; playlistItem.RequiredMods.AddRange(mods); + playlistItem.AllowedMods.AddRange(allowedMods); apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity. apiRoom.Playlist.Add(playlistItem); diff --git a/osu.Game/Online/Rooms/BeatmapAvailability.cs b/osu.Game/Online/Rooms/BeatmapAvailability.cs index 38bd236718..a83327aad5 100644 --- a/osu.Game/Online/Rooms/BeatmapAvailability.cs +++ b/osu.Game/Online/Rooms/BeatmapAvailability.cs @@ -23,17 +23,17 @@ namespace osu.Game.Online.Rooms /// The beatmap's downloading progress, null when not in state. /// [Key(1)] - public readonly double? DownloadProgress; + public readonly float? DownloadProgress; [JsonConstructor] - public BeatmapAvailability(DownloadState state, double? downloadProgress = null) + public BeatmapAvailability(DownloadState state, float? downloadProgress = null) { State = state; DownloadProgress = downloadProgress; } public static BeatmapAvailability NotDownloaded() => new BeatmapAvailability(DownloadState.NotDownloaded); - public static BeatmapAvailability Downloading(double progress) => new BeatmapAvailability(DownloadState.Downloading, progress); + public static BeatmapAvailability Downloading(float progress) => new BeatmapAvailability(DownloadState.Downloading, progress); public static BeatmapAvailability Importing() => new BeatmapAvailability(DownloadState.Importing); public static BeatmapAvailability LocallyAvailable() => new BeatmapAvailability(DownloadState.LocallyAvailable); diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs new file mode 100644 index 0000000000..ad4b3c5151 --- /dev/null +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Beatmaps; + +namespace osu.Game.Online.Rooms +{ + /// + /// Represent a checksum-verifying beatmap availability tracker usable for online play screens. + /// + /// This differs from a regular download tracking composite as this accounts for the + /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. + /// + public class OnlinePlayBeatmapAvailablilityTracker : DownloadTrackingComposite + { + public readonly IBindable SelectedItem = new Bindable(); + + /// + /// The availability state of the currently selected playlist item. + /// + public IBindable Availability => availability; + + private readonly Bindable availability = new Bindable(); + + public OnlinePlayBeatmapAvailablilityTracker() + { + State.BindValueChanged(_ => updateAvailability()); + Progress.BindValueChanged(_ => updateAvailability(), true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(item => Model.Value = item.NewValue?.Beatmap.Value.BeatmapSet, true); + } + + protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet) + { + int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; + string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; + + var matchingBeatmap = databasedSet.Beatmaps.FirstOrDefault(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); + + if (matchingBeatmap == null) + { + Logger.Log("The imported beatmap set does not match the online version.", LoggingTarget.Runtime, LogLevel.Important); + return false; + } + + return true; + } + + protected override bool IsModelAvailableLocally() + { + int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; + string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; + + var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); + return beatmap?.BeatmapSet.DeletePending == false; + } + + private void updateAvailability() + { + switch (State.Value) + { + case DownloadState.NotDownloaded: + availability.Value = BeatmapAvailability.NotDownloaded(); + break; + + case DownloadState.Downloading: + availability.Value = BeatmapAvailability.Downloading((float)Progress.Value); + break; + + case DownloadState.Importing: + availability.Value = BeatmapAvailability.Importing(); + break; + + case DownloadState.LocallyAvailable: + availability.Value = BeatmapAvailability.LocallyAvailable(); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(State)); + } + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 5acd6bc73d..a00cd5e6a0 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -468,6 +468,12 @@ namespace osu.Game private void modsChanged(ValueChangedEvent> mods) { updateModDefaults(); + + if (!ModUtils.CheckValidForGameplay(mods.NewValue, out var invalid)) + { + // ensure we always have a valid set of mods. + SelectedMods.Value = mods.NewValue.Except(invalid).ToArray(); + } } private void updateModDefaults() diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 5926d11c03..86ce724390 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Chat @@ -24,7 +25,7 @@ namespace osu.Game.Overlays.Chat { public readonly Channel Channel; protected FillFlowContainer ChatLineFlow; - private OsuScrollContainer scroll; + private ChannelScrollContainer scroll; private bool scrollbarVisible = true; @@ -56,7 +57,7 @@ namespace osu.Game.Overlays.Chat { RelativeSizeAxes = Axes.Both, Masking = true, - Child = scroll = new OsuScrollContainer + Child = scroll = new ChannelScrollContainer { ScrollbarVisible = scrollbarVisible, RelativeSizeAxes = Axes.Both, @@ -80,12 +81,6 @@ namespace osu.Game.Overlays.Chat Channel.PendingMessageResolved += pendingMessageResolved; } - protected override void LoadComplete() - { - base.LoadComplete(); - scrollToEnd(); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -113,8 +108,6 @@ namespace osu.Game.Overlays.Chat ChatLineFlow.Clear(); } - bool shouldScrollToEnd = scroll.IsScrolledToEnd(10) || !chatLines.Any() || newMessages.Any(m => m is LocalMessage); - // Add up to last Channel.MAX_HISTORY messages var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MAX_HISTORY)); @@ -153,8 +146,10 @@ namespace osu.Game.Overlays.Chat } } - if (shouldScrollToEnd) - scrollToEnd(); + // due to the scroll adjusts from old messages removal above, a scroll-to-end must be enforced, + // to avoid making the container think the user has scrolled back up and unwantedly disable auto-scrolling. + if (newMessages.Any(m => m is LocalMessage)) + scroll.ScrollToEnd(); }); private void pendingMessageResolved(Message existing, Message updated) => Schedule(() => @@ -178,8 +173,6 @@ namespace osu.Game.Overlays.Chat private IEnumerable chatLines => ChatLineFlow.Children.OfType(); - private void scrollToEnd() => ScheduleAfterChildren(() => scroll.ScrollToEnd()); - public class DaySeparator : Container { public float TextSize @@ -243,5 +236,51 @@ namespace osu.Game.Overlays.Chat }; } } + + /// + /// An with functionality to automatically scroll whenever the maximum scrollable distance increases. + /// + private class ChannelScrollContainer : UserTrackingScrollContainer + { + /// + /// The chat will be automatically scrolled to end if and only if + /// the distance between the current scroll position and the end of the scroll + /// is less than this value. + /// + private const float auto_scroll_leniency = 10f; + + private float? lastExtent; + + protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + { + base.OnUserScroll(value, animated, distanceDecay); + lastExtent = null; + } + + protected override void Update() + { + base.Update(); + + // If the user has scrolled to the bottom of the container, we should resume tracking new content. + if (UserScrolling && IsScrolledToEnd(auto_scroll_leniency)) + CancelUserScroll(); + + // If the user hasn't overridden our behaviour and there has been new content added to the container, we should update our scroll position to track it. + bool requiresScrollUpdate = !UserScrolling && (lastExtent == null || Precision.AlmostBigger(ScrollableExtent, lastExtent.Value)); + + if (requiresScrollUpdate) + { + // Schedule required to allow FillFlow to be the correct size. + Schedule(() => + { + if (!UserScrolling) + { + ScrollToEnd(); + lastExtent = ScrollableExtent; + } + }); + } + } + } } } diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index 573d1e5355..ecbcba7ad3 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -11,31 +11,30 @@ using System; using System.Linq; using System.Collections.Generic; using System.Threading; +using Humanizer; using osu.Framework.Input.Events; using osu.Game.Graphics; namespace osu.Game.Overlays.Mods { - public abstract class ModSection : Container + public class ModSection : CompositeDrawable { - private readonly OsuSpriteText headerLabel; + private readonly Drawable header; public FillFlowContainer ButtonsContainer { get; } public Action Action; - protected abstract Key[] ToggleKeys { get; } - public abstract ModType ModType { get; } - public string Header - { - get => headerLabel.Text; - set => headerLabel.Text = value; - } + public Key[] ToggleKeys; + + public readonly ModType ModType; public IEnumerable SelectedMods => buttons.Select(b => b.SelectedMod).Where(m => m != null); private CancellationTokenSource modsLoadCts; + protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0; + /// /// True when all mod icons have completed loading. /// @@ -52,7 +51,11 @@ namespace osu.Game.Overlays.Mods return new ModButton(m) { - SelectionChanged = Action, + SelectionChanged = mod => + { + ModButtonStateChanged(mod); + Action?.Invoke(mod); + }, }; }).ToArray(); @@ -61,7 +64,7 @@ namespace osu.Game.Overlays.Mods if (modContainers.Length == 0) { ModIconsLoaded = true; - headerLabel.Hide(); + header.Hide(); Hide(); return; } @@ -76,11 +79,15 @@ namespace osu.Game.Overlays.Mods buttons = modContainers.OfType().ToArray(); - headerLabel.FadeIn(200); + header.FadeIn(200); this.FadeIn(200); } } + protected virtual void ModButtonStateChanged(Mod mod) + { + } + private ModButton[] buttons = Array.Empty(); protected override bool OnKeyDown(KeyDownEvent e) @@ -97,30 +104,75 @@ namespace osu.Game.Overlays.Mods return base.OnKeyDown(e); } - public void DeselectAll() => DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); + private const double initial_multiple_selection_delay = 120; + + private double selectionDelay = initial_multiple_selection_delay; + private double lastSelection; + + private readonly Queue pendingSelectionOperations = new Queue(); + + protected override void Update() + { + base.Update(); + + if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay) + { + if (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) + { + dequeuedAction(); + + // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements). + selectionDelay = Math.Max(30, selectionDelay * 0.8f); + lastSelection = Time.Current; + } + else + { + // reset the selection delay after all animations have been completed. + // this will cause the next action to be immediately performed. + selectionDelay = initial_multiple_selection_delay; + } + } + } + + /// + /// Selects all mods. + /// + public void SelectAll() + { + pendingSelectionOperations.Clear(); + + foreach (var button in buttons.Where(b => !b.Selected)) + pendingSelectionOperations.Enqueue(() => button.SelectAt(0)); + } + + /// + /// Deselects all mods. + /// + public void DeselectAll() + { + pendingSelectionOperations.Clear(); + DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); + } /// /// Deselect one or more mods in this section. /// /// The types of s which should be deselected. - /// Set to true to bypass animations and update selections immediately. + /// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow. public void DeselectTypes(IEnumerable modTypes, bool immediate = false) { - int delay = 0; - foreach (var button in buttons) { - Mod selected = button.SelectedMod; - if (selected == null) continue; + if (button.SelectedMod == null) continue; foreach (var type in modTypes) { - if (type.IsInstanceOfType(selected)) + if (type.IsInstanceOfType(button.SelectedMod)) { if (immediate) button.Deselect(); else - Scheduler.AddDelayed(button.Deselect, delay += 50); + pendingSelectionOperations.Enqueue(button.Deselect); } } } @@ -130,13 +182,13 @@ namespace osu.Game.Overlays.Mods /// Updates all buttons with the given list of selected mods. /// /// The new list of selected mods to select. - public void UpdateSelectedMods(IReadOnlyList newSelectedMods) + public void UpdateSelectedButtons(IReadOnlyList newSelectedMods) { foreach (var button in buttons) - updateButtonMods(button, newSelectedMods); + updateButtonSelection(button, newSelectedMods); } - private void updateButtonMods(ModButton button, IReadOnlyList newSelectedMods) + private void updateButtonSelection(ModButton button, IReadOnlyList newSelectedMods) { foreach (var mod in newSelectedMods) { @@ -153,23 +205,19 @@ namespace osu.Game.Overlays.Mods button.Deselect(); } - protected ModSection() + public ModSection(ModType type) { + ModType = type; + AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; Origin = Anchor.TopCentre; Anchor = Anchor.TopCentre; - Children = new Drawable[] + InternalChildren = new[] { - headerLabel = new OsuSpriteText - { - Origin = Anchor.TopLeft, - Anchor = Anchor.TopLeft, - Position = new Vector2(0f, 0f), - Font = OsuFont.GetFont(weight: FontWeight.Bold) - }, + header = CreateHeader(type.Humanize(LetterCasing.Title)), ButtonsContainer = new FillFlowContainer { AutoSizeAxes = Axes.Y, @@ -185,5 +233,20 @@ namespace osu.Game.Overlays.Mods }, }; } + + protected virtual Drawable CreateHeader(string text) => new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = text + }; + + /// + /// Play out all remaining animations immediately to leave mods in a good (final) state. + /// + public void FlushAnimation() + { + while (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) + dequeuedAction(); + } } } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 1258ba719d..93fe693937 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -19,30 +20,54 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; -using osu.Game.Overlays.Mods.Sections; using osu.Game.Rulesets.Mods; using osu.Game.Screens; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Mods { - public class ModSelectOverlay : WaveOverlayContainer + public abstract class ModSelectOverlay : WaveOverlayContainer { - private readonly Func isValidMod; public const float HEIGHT = 510; protected readonly TriangleButton DeselectAllButton; protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CloseButton; + protected readonly Drawable MultiplierSection; protected readonly OsuSpriteText MultiplierLabel; + protected readonly FillFlowContainer FooterContainer; + protected override bool BlockNonPositionalInput => false; protected override bool DimMainContent => false; + /// + /// Whether s underneath the same instance should appear as stacked buttons. + /// + protected virtual bool Stacked => true; + + [NotNull] + private Func isValidMod = m => true; + + /// + /// A function that checks whether a given mod is selectable. + /// + [NotNull] + public Func IsValidMod + { + get => isValidMod; + set + { + isValidMod = value ?? throw new ArgumentNullException(nameof(value)); + updateAvailableMods(); + } + } + protected readonly FillFlowContainer ModSectionsContainer; protected readonly ModSettingsContainer ModSettingsContainer; @@ -57,14 +82,10 @@ namespace osu.Game.Overlays.Mods private const float content_width = 0.8f; private const float footer_button_spacing = 20; - private readonly FillFlowContainer footerContainer; - private SampleChannel sampleOn, sampleOff; - public ModSelectOverlay(Func isValidMod = null) + protected ModSelectOverlay() { - this.isValidMod = isValidMod ?? (m => true); - Waves.FirstWaveColour = Color4Extensions.FromHex(@"19b0e2"); Waves.SecondWaveColour = Color4Extensions.FromHex(@"2280a2"); Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); @@ -190,13 +211,31 @@ namespace osu.Game.Overlays.Mods Width = content_width, LayoutDuration = 200, LayoutEasing = Easing.OutQuint, - Children = new ModSection[] + Children = new[] { - new DifficultyReductionSection { Action = modButtonPressed }, - new DifficultyIncreaseSection { Action = modButtonPressed }, - new AutomationSection { Action = modButtonPressed }, - new ConversionSection { Action = modButtonPressed }, - new FunSection { Action = modButtonPressed }, + CreateModSection(ModType.DifficultyReduction).With(s => + { + s.ToggleKeys = new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }; + s.Action = modButtonPressed; + }), + CreateModSection(ModType.DifficultyIncrease).With(s => + { + s.ToggleKeys = new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }; + s.Action = modButtonPressed; + }), + CreateModSection(ModType.Automation).With(s => + { + s.ToggleKeys = new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }; + s.Action = modButtonPressed; + }), + CreateModSection(ModType.Conversion).With(s => + { + s.Action = modButtonPressed; + }), + CreateModSection(ModType.Fun).With(s => + { + s.Action = modButtonPressed; + }), } }, } @@ -231,7 +270,7 @@ namespace osu.Game.Overlays.Mods Colour = new Color4(172, 20, 116, 255), Alpha = 0.5f, }, - footerContainer = new FillFlowContainer + FooterContainer = new FillFlowContainer { Origin = Anchor.BottomCentre, Anchor = Anchor.BottomCentre, @@ -245,7 +284,7 @@ namespace osu.Game.Overlays.Mods Vertical = 15, Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] + Children = new[] { DeselectAllButton = new TriangleButton { @@ -272,7 +311,7 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, - new FillFlowContainer + MultiplierSection = new FillFlowContainer { AutoSizeAxes = Axes.Both, Spacing = new Vector2(footer_button_spacing / 2, 0), @@ -328,33 +367,25 @@ namespace osu.Game.Overlays.Mods refreshSelectedMods(); } - /// - /// Deselect one or more mods. - /// - /// The types of s which should be deselected. - /// Set to true to bypass animations and update selections immediately. - private void deselectTypes(Type[] modTypes, bool immediate = false) - { - if (modTypes.Length == 0) return; - - foreach (var section in ModSectionsContainer.Children) - section.DeselectTypes(modTypes, immediate); - } - protected override void LoadComplete() { base.LoadComplete(); - availableMods.BindValueChanged(availableModsChanged, true); - SelectedMods.BindValueChanged(selectedModsChanged, true); + availableMods.BindValueChanged(_ => updateAvailableMods(), true); + SelectedMods.BindValueChanged(_ => updateSelectedButtons(), true); } protected override void PopOut() { base.PopOut(); - footerContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - footerContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); + foreach (var section in ModSectionsContainer) + { + section.FlushAnimation(); + } + + FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); + FooterContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); foreach (var section in ModSectionsContainer.Children) { @@ -368,8 +399,8 @@ namespace osu.Game.Overlays.Mods { base.PopIn(); - footerContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); - footerContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); + FooterContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); + FooterContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); foreach (var section in ModSectionsContainer.Children) { @@ -400,18 +431,53 @@ namespace osu.Game.Overlays.Mods public override bool OnPressed(GlobalAction action) => false; // handled by back button - private void availableModsChanged(ValueChangedEvent>> mods) + private void updateAvailableMods() { - if (mods.NewValue == null) return; + if (availableMods?.Value == null) + return; foreach (var section in ModSectionsContainer.Children) - section.Mods = mods.NewValue[section.ModType].Where(isValidMod); + { + IEnumerable modEnumeration = availableMods.Value[section.ModType]; + + if (!Stacked) + modEnumeration = ModUtils.FlattenMods(modEnumeration); + + section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null); + } + + updateSelectedButtons(); } - private void selectedModsChanged(ValueChangedEvent> mods) + /// + /// Returns a valid form of a given if possible, or null otherwise. + /// + /// + /// This is a recursive process during which any invalid mods are culled while preserving structures where possible. + /// + /// The to check. + /// A valid form of if exists, or null otherwise. + [CanBeNull] + private Mod getValidModOrNull([NotNull] Mod mod) { + if (!(mod is MultiMod multi)) + return IsValidMod(mod) ? mod : null; + + var validSubset = multi.Mods.Select(getValidModOrNull).Where(m => m != null).ToArray(); + + if (validSubset.Length == 0) + return null; + + return validSubset.Length == 1 ? validSubset[0] : new MultiMod(validSubset); + } + + private void updateSelectedButtons() + { + // Enumeration below may update the bindable list. + var selectedMods = SelectedMods.Value.ToList(); + foreach (var section in ModSectionsContainer.Children) - section.UpdateSelectedMods(mods.NewValue); + section.UpdateSelectedButtons(selectedMods); updateMods(); } @@ -438,22 +504,42 @@ namespace osu.Game.Overlays.Mods { if (selectedMod != null) { - if (State.Value == Visibility.Visible) sampleOn?.Play(); + if (State.Value == Visibility.Visible) + Scheduler.AddOnce(playSelectedSound); - deselectTypes(selectedMod.IncompatibleMods, true); + OnModSelected(selectedMod); if (selectedMod.RequiresConfiguration) ModSettingsContainer.Show(); } else { - if (State.Value == Visibility.Visible) sampleOff?.Play(); + if (State.Value == Visibility.Visible) + Scheduler.AddOnce(playDeselectedSound); } refreshSelectedMods(); } + private void playSelectedSound() => sampleOn?.Play(); + private void playDeselectedSound() => sampleOff?.Play(); + + /// + /// Invoked when a new has been selected. + /// + /// The that has been selected. + protected virtual void OnModSelected(Mod mod) + { + } + private void refreshSelectedMods() => SelectedMods.Value = ModSectionsContainer.Children.SelectMany(s => s.SelectedMods).ToArray(); + /// + /// Creates a that groups s with the same . + /// + /// The of s in the section. + /// The . + protected virtual ModSection CreateModSection(ModType type) => new ModSection(type); + #region Disposal protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Overlays/Mods/Sections/AutomationSection.cs b/osu.Game/Overlays/Mods/Sections/AutomationSection.cs deleted file mode 100644 index a2d7fec15f..0000000000 --- a/osu.Game/Overlays/Mods/Sections/AutomationSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class AutomationSection : ModSection - { - protected override Key[] ToggleKeys => new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }; - public override ModType ModType => ModType.Automation; - - public AutomationSection() - { - Header = @"Automation"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/ConversionSection.cs b/osu.Game/Overlays/Mods/Sections/ConversionSection.cs deleted file mode 100644 index 24fd8c30dd..0000000000 --- a/osu.Game/Overlays/Mods/Sections/ConversionSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class ConversionSection : ModSection - { - protected override Key[] ToggleKeys => null; - public override ModType ModType => ModType.Conversion; - - public ConversionSection() - { - Header = @"Conversion"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs b/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs deleted file mode 100644 index 0b7ccd1f25..0000000000 --- a/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class DifficultyIncreaseSection : ModSection - { - protected override Key[] ToggleKeys => new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }; - public override ModType ModType => ModType.DifficultyIncrease; - - public DifficultyIncreaseSection() - { - Header = @"Difficulty Increase"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs b/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs deleted file mode 100644 index 508e92508b..0000000000 --- a/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class DifficultyReductionSection : ModSection - { - protected override Key[] ToggleKeys => new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }; - public override ModType ModType => ModType.DifficultyReduction; - - public DifficultyReductionSection() - { - Header = @"Difficulty Reduction"; - } - } -} diff --git a/osu.Game/Overlays/Mods/Sections/FunSection.cs b/osu.Game/Overlays/Mods/Sections/FunSection.cs deleted file mode 100644 index af1f5836b1..0000000000 --- a/osu.Game/Overlays/Mods/Sections/FunSection.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mods; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods.Sections -{ - public class FunSection : ModSection - { - protected override Key[] ToggleKeys => null; - public override ModType ModType => ModType.Fun; - - public FunSection() - { - Header = @"Fun"; - } - } -} diff --git a/osu.Game/Overlays/Mods/SoloModSelectOverlay.cs b/osu.Game/Overlays/Mods/SoloModSelectOverlay.cs new file mode 100644 index 0000000000..d039ad1f98 --- /dev/null +++ b/osu.Game/Overlays/Mods/SoloModSelectOverlay.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + public class SoloModSelectOverlay : ModSelectOverlay + { + protected override void OnModSelected(Mod mod) + { + base.OnModSelected(mod); + + foreach (var section in ModSectionsContainer.Children) + section.DeselectTypes(mod.IncompatibleMods, true); + } + } +} diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 04a2e052fa..cae5da3d16 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -29,8 +29,6 @@ namespace osu.Game.Rulesets.UI private const float size = 80; - private readonly ModType type; - public virtual string TooltipText => showTooltip ? mod.IconTooltip : null; private Mod mod; @@ -42,10 +40,18 @@ namespace osu.Game.Rulesets.UI set { mod = value; - updateMod(value); + + if (IsLoaded) + updateMod(value); } } + [Resolved] + private OsuColour colours { get; set; } + + private Color4 backgroundColour; + private Color4 highlightedColour; + /// /// Construct a new instance. /// @@ -56,8 +62,6 @@ namespace osu.Game.Rulesets.UI this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); this.showTooltip = showTooltip; - type = mod.Type; - Size = new Vector2(size); Children = new Drawable[] @@ -89,6 +93,13 @@ namespace osu.Game.Rulesets.UI Icon = FontAwesome.Solid.Question }, }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Selected.BindValueChanged(_ => updateColour()); updateMod(mod); } @@ -102,20 +113,14 @@ namespace osu.Game.Rulesets.UI { modIcon.FadeOut(); modAcronym.FadeIn(); - return; + } + else + { + modIcon.FadeIn(); + modAcronym.FadeOut(); } - modIcon.FadeIn(); - modAcronym.FadeOut(); - } - - private Color4 backgroundColour; - private Color4 highlightedColour; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - switch (type) + switch (value.Type) { default: case ModType.DifficultyIncrease: @@ -149,12 +154,13 @@ namespace osu.Game.Rulesets.UI modIcon.Colour = colours.Yellow; break; } + + updateColour(); } - protected override void LoadComplete() + private void updateColour() { - base.LoadComplete(); - Selected.BindValueChanged(selected => background.Colour = selected.NewValue ? highlightedColour : backgroundColour, true); + background.Colour = Selected.Value ? highlightedColour : backgroundColour; } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 0955f32790..6ffdad211b 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -91,7 +91,11 @@ namespace osu.Game.Rulesets.UI.Scrolling scrollingInfo = new LocalScrollingInfo(); scrollingInfo.Direction.BindTo(Direction); scrollingInfo.TimeRange.BindTo(TimeRange); + } + [BackgroundDependencyLoader] + private void load() + { switch (VisualisationMethod) { case ScrollVisualisationMethod.Sequential: @@ -106,11 +110,7 @@ namespace osu.Game.Rulesets.UI.Scrolling scrollingInfo.Algorithm = new ConstantScrollAlgorithm(); break; } - } - [BackgroundDependencyLoader] - private void load() - { double lastObjectTime = Objects.LastOrDefault()?.GetEndTime() ?? double.MaxValue; double baseBeatLength = TimingControlPoint.DEFAULT_BEAT_LENGTH; diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 64ddba669d..6eb675976a 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -1,77 +1,29 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { public abstract class ReadyButton : TriangleButton { - public readonly Bindable SelectedItem = new Bindable(); - public new readonly BindableBool Enabled = new BindableBool(); - [Resolved] - protected IBindable GameBeatmap { get; private set; } - - [Resolved] - private BeatmapManager beatmaps { get; set; } - - private bool hasBeatmap; - - private IBindable> managerUpdated; - private IBindable> managerRemoved; + private IBindable availability; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OnlinePlayBeatmapAvailablilityTracker beatmapTracker) { - managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); - managerUpdated.BindValueChanged(beatmapUpdated); - managerRemoved = beatmaps.ItemRemoved.GetBoundCopy(); - managerRemoved.BindValueChanged(beatmapRemoved); + availability = beatmapTracker.Availability.GetBoundCopy(); - SelectedItem.BindValueChanged(item => updateSelectedItem(item.NewValue), true); + availability.BindValueChanged(_ => updateState()); + Enabled.BindValueChanged(_ => updateState(), true); } - private void updateSelectedItem(PlaylistItem _) => Scheduler.AddOnce(updateBeatmapState); - private void beatmapUpdated(ValueChangedEvent> _) => Scheduler.AddOnce(updateBeatmapState); - private void beatmapRemoved(ValueChangedEvent> _) => Scheduler.AddOnce(updateBeatmapState); - - private void updateBeatmapState() - { - int? beatmapId = SelectedItem.Value?.Beatmap.Value?.OnlineBeatmapID; - string checksum = SelectedItem.Value?.Beatmap.Value?.MD5Hash; - - if (beatmapId == null || checksum == null) - return; - - var databasedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); - - hasBeatmap = databasedBeatmap?.BeatmapSet?.DeletePending == false; - } - - protected override void Update() - { - base.Update(); - - updateEnabledState(); - } - - private void updateEnabledState() - { - if (GameBeatmap.Value == null || SelectedItem.Value == null) - { - base.Enabled.Value = false; - return; - } - - base.Enabled.Value = hasBeatmap && Enabled.Value; - } + private void updateState() => base.Enabled.Value = availability.Value.State == DownloadState.LocallyAvailable && Enabled.Value; } } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index f8982582d5..23c713a2c1 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -124,101 +124,111 @@ namespace osu.Game.Screens.OnlinePlay modDisplay.Current.Value = requiredMods.ToArray(); } - protected override Drawable CreateContent() => maskingContainer = new Container + protected override Drawable CreateContent() { - RelativeSizeAxes = Axes.X, - Height = 50, - Masking = true, - CornerRadius = 10, - Children = new Drawable[] + Action fontParameters = s => s.Font = OsuFont.Default.With(weight: FontWeight.SemiBold); + + return maskingContainer = new Container { - new Box // A transparent box that forces the border to be drawn if the panel background is opaque + RelativeSizeAxes = Axes.X, + Height = 50, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }, - new PanelBackground - { - RelativeSizeAxes = Axes.Both, - Beatmap = { BindTarget = beatmap } - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 8 }, - Spacing = new Vector2(8, 0), - Direction = FillDirection.Horizontal, - Children = new Drawable[] + new Box // A transparent box that forces the border to be drawn if the panel background is opaque { - difficultyIconContainer = new Container + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new PanelBackground + { + RelativeSizeAxes = Axes.Both, + Beatmap = { BindTarget = beatmap } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 8 }, + Spacing = new Vector2(8, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + difficultyIconContainer = new Container { - beatmapText = new LinkFlowContainer { AutoSizeAxes = Axes.Both }, - new FillFlowContainer + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), - Children = new Drawable[] + beatmapText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, + new FillFlowContainer { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), - Children = new Drawable[] + new FillFlowContainer { - authorText = new LinkFlowContainer { AutoSizeAxes = Axes.Both }, - explicitContentPill = new ExplicitContentBeatmapPill + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] { - Alpha = 0f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Top = 3f }, - } + authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, + explicitContentPill = new ExplicitContentBeatmapPill + { + Alpha = 0f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Top = 3f }, + } + }, }, - }, - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Child = modDisplay = new ModDisplay + new Container { - Scale = new Vector2(0.4f), - DisplayUnrankedText = false, - ExpansionMode = ExpansionMode.AlwaysExpanded + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = modDisplay = new ModDisplay + { + Scale = new Vector2(0.4f), + DisplayUnrankedText = false, + ExpansionMode = ExpansionMode.AlwaysExpanded + } } } } } } } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + X = -10, + ChildrenEnumerable = CreateButtons().Select(button => button.With(b => + { + b.Anchor = Anchor.Centre; + b.Origin = Anchor.Centre; + })) } - }, - new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - X = -18, - ChildrenEnumerable = CreateButtons() } - } - }; + }; + } protected virtual IEnumerable CreateButtons() => new Drawable[] @@ -227,14 +237,29 @@ namespace osu.Game.Screens.OnlinePlay { Size = new Vector2(50, 30) }, - new IconButton + new PlaylistRemoveButton { - Icon = FontAwesome.Solid.MinusSquare, + Size = new Vector2(30, 30), Alpha = allowEdit ? 1 : 0, Action = () => RequestDeletion?.Invoke(Model), }, }; + public class PlaylistRemoveButton : GrayButton + { + public PlaylistRemoveButton() + : base(FontAwesome.Solid.MinusSquare) + { + TooltipText = "Remove from playlist"; + } + + [BackgroundDependencyLoader] + private void load() + { + Icon.Scale = new Vector2(0.8f); + } + } + protected override bool OnClick(ClickEvent e) { if (allowSelection) @@ -318,24 +343,18 @@ namespace osu.Game.Screens.OnlinePlay Colour = Color4.Black, Width = 0.4f, }, - // Piecewise-linear gradient with 3 segments to make it appear smoother + // Piecewise-linear gradient with 2 segments to make it appear smoother new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), - Width = 0.05f, + Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.7f)), + Width = 0.4f, }, new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), - Width = 0.2f, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), - Width = 0.05f, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.7f), new Color4(0, 0, 0, 0.4f)), + Width = 0.4f, }, } } diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs new file mode 100644 index 0000000000..a3cc383b67 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay +{ + public class FooterButtonFreeMods : FooterButton, IHasCurrentValue> + { + public Bindable> Current + { + get => modDisplay.Current; + set => modDisplay.Current = value; + } + + private readonly ModDisplay modDisplay; + + public FooterButtonFreeMods() + { + ButtonContentContainer.Add(modDisplay = new ModDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + DisplayUnrankedText = false, + Scale = new Vector2(0.8f), + ExpansionMode = ExpansionMode.AlwaysContracted, + }); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + SelectedColour = colours.Yellow; + DeselectedColour = SelectedColour.Opacity(0.5f); + Text = @"freemods"; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateModDisplay(), true); + } + + private void updateModDisplay() + { + if (Current.Value?.Count > 0) + modDisplay.FadeIn(); + else + modDisplay.FadeOut(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs new file mode 100644 index 0000000000..7bc226bb3f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -0,0 +1,145 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Screens.OnlinePlay +{ + /// + /// A used for free-mod selection in online play. + /// + public class FreeModSelectOverlay : ModSelectOverlay + { + protected override bool Stacked => false; + + public new Func IsValidMod + { + get => base.IsValidMod; + set => base.IsValidMod = m => m.HasImplementation && !m.RequiresConfiguration && !(m is ModAutoplay) && value(m); + } + + public FreeModSelectOverlay() + { + IsValidMod = m => true; + + CustomiseButton.Alpha = 0; + MultiplierSection.Alpha = 0; + DeselectAllButton.Alpha = 0; + + Drawable selectAllButton; + Drawable deselectAllButton; + + FooterContainer.AddRange(new[] + { + selectAllButton = new TriangleButton + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Width = 180, + Text = "Select All", + Action = selectAll, + }, + // Unlike the base mod select overlay, this button deselects mods instantaneously. + deselectAllButton = new TriangleButton + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Width = 180, + Text = "Deselect All", + Action = deselectAll, + }, + }); + + FooterContainer.SetLayoutPosition(selectAllButton, -2); + FooterContainer.SetLayoutPosition(deselectAllButton, -1); + } + + private void selectAll() + { + foreach (var section in ModSectionsContainer.Children) + section.SelectAll(); + } + + private void deselectAll() + { + foreach (var section in ModSectionsContainer.Children) + section.DeselectAll(); + } + + protected override ModSection CreateModSection(ModType type) => new FreeModSection(type); + + private class FreeModSection : ModSection + { + private HeaderCheckbox checkbox; + + public FreeModSection(ModType type) + : base(type) + { + } + + protected override Drawable CreateHeader(string text) => new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Child = checkbox = new HeaderCheckbox + { + LabelText = text, + Changed = onCheckboxChanged + } + }; + + private void onCheckboxChanged(bool value) + { + if (value) + SelectAll(); + else + DeselectAll(); + } + + protected override void ModButtonStateChanged(Mod mod) + { + base.ModButtonStateChanged(mod); + + if (!SelectionAnimationRunning) + { + var validButtons = ButtonsContainer.OfType().Where(b => b.Mod.HasImplementation); + checkbox.Current.Value = validButtons.All(b => b.Selected); + } + } + } + + private class HeaderCheckbox : OsuCheckbox + { + public Action Changed; + + protected override bool PlaySoundsOnUserChange => false; + + public HeaderCheckbox() + : base(false) + + { + } + + protected override void ApplyLabelParameters(SpriteText text) + { + base.ApplyLabelParameters(text); + + text.Font = OsuFont.GetFont(weight: FontWeight.Bold); + } + + protected override void OnUserChange(bool value) + { + base.OnUserChange(value); + Changed?.Invoke(value); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/Footer.cs b/osu.Game/Screens/OnlinePlay/Match/Components/Footer.cs index 5c27d78d50..e91c46beed 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/Footer.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/Footer.cs @@ -3,13 +3,11 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Playlists; using osuTK; @@ -20,7 +18,6 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components public const float HEIGHT = 50; public Action OnStart; - public readonly Bindable SelectedItem = new Bindable(); private readonly Drawable background; @@ -37,7 +34,6 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(600, 50), - SelectedItem = { BindTarget = SelectedItem }, Action = () => OnStart?.Invoke() } }; diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 2449563c73..54d6ffe38a 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -29,6 +30,11 @@ namespace osu.Game.Screens.OnlinePlay.Match [Resolved(typeof(Room), nameof(Room.Playlist))] protected BindableList Playlist { get; private set; } + /// + /// Any mods applied by/to the local user. + /// + protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); + [Resolved] private MusicController music { get; set; } @@ -40,6 +46,20 @@ namespace osu.Game.Screens.OnlinePlay.Match private IBindable> managerUpdated; + [Cached] + protected OnlinePlayBeatmapAvailablilityTracker BeatmapAvailablilityTracker { get; } + + protected RoomSubScreen() + { + AddInternal(BeatmapAvailablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + { + SelectedItem = { BindTarget = SelectedItem } + }); + } + + protected override void ClearInternal(bool disposeChildren = true) => + throw new InvalidOperationException($"{nameof(RoomSubScreen)}'s children should not be cleared as it will remove required components"); + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -55,6 +75,8 @@ namespace osu.Game.Screens.OnlinePlay.Match managerUpdated = beatmapManager.ItemUpdated.GetBoundCopy(); managerUpdated.BindValueChanged(beatmapUpdated); + + UserMods.BindValueChanged(_ => updateMods()); } public override void OnEntering(IScreen last) @@ -73,6 +95,7 @@ namespace osu.Game.Screens.OnlinePlay.Match { base.OnResuming(last); beginHandlingTrack(); + updateMods(); } public override bool OnExiting(IScreen next) @@ -95,12 +118,17 @@ namespace osu.Game.Screens.OnlinePlay.Match { updateWorkingBeatmap(); - var item = SelectedItem.Value; + if (SelectedItem.Value == null) + return; - Mods.Value = item?.RequiredMods?.ToArray() ?? Array.Empty(); + // Remove any user mods that are no longer allowed. + UserMods.Value = UserMods.Value + .Where(m => SelectedItem.Value.AllowedMods.Any(a => m.GetType() == a.GetType())) + .ToList(); - if (item?.Ruleset != null) - Ruleset.Value = item.Ruleset.Value; + updateMods(); + + Ruleset.Value = SelectedItem.Value.Ruleset.Value; } private void beatmapUpdated(ValueChangedEvent> weakSet) => Schedule(updateWorkingBeatmap); @@ -115,6 +143,14 @@ namespace osu.Game.Screens.OnlinePlay.Match Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); } + private void updateMods() + { + if (SelectedItem.Value == null) + return; + + Mods.Value = UserMods.Value.Concat(SelectedItem.Value.RequiredMods).ToList(); + } + private void beginHandlingTrack() { Beatmap.BindValueChanged(applyLoopingToTrack, true); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index bbf861fac3..fdc1ae9d3c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -3,13 +3,11 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Online.Rooms; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match @@ -18,8 +16,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public const float HEIGHT = 50; - public readonly Bindable SelectedItem = new Bindable(); - public Action OnReadyClick { set => readyButton.OnReadyClick = value; @@ -41,7 +37,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(600, 50), - SelectedItem = { BindTarget = SelectedItem } } }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 04030cdbfd..389a2380fa 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -13,7 +13,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osuTK; @@ -21,8 +20,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerReadyButton : MultiplayerRoomComposite { - public Bindable SelectedItem => button.SelectedItem; - public Action OnReadyClick { set => button.Action = value; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index ebc06d2445..84e8849726 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -1,70 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; -using Humanizer; using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerMatchSongSelect : SongSelect, IOnlinePlaySubScreen + public class MultiplayerMatchSongSelect : OnlinePlaySongSelect { - public string ShortTitle => "song selection"; - - public override string Title => ShortTitle.Humanize(); - - [Resolved(typeof(Room), nameof(Room.Playlist))] - private BindableList playlist { get; set; } - [Resolved] private StatefulMultiplayerClient client { get; set; } private LoadingLayer loadingLayer; - private WorkingBeatmap initialBeatmap; - private RulesetInfo initialRuleset; - private IReadOnlyList initialMods; - - private bool itemSelected; - - public MultiplayerMatchSongSelect() - { - Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; - } - [BackgroundDependencyLoader] private void load() { AddInternal(loadingLayer = new LoadingLayer(true)); - initialBeatmap = Beatmap.Value; - initialRuleset = Ruleset.Value; - initialMods = Mods.Value.ToList(); } - protected override bool OnStart() + protected override void SelectItem(PlaylistItem item) { - itemSelected = true; - var item = new PlaylistItem(); - - item.Beatmap.Value = Beatmap.Value.BeatmapInfo; - item.Ruleset.Value = Ruleset.Value; - - item.RequiredMods.Clear(); - item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); - // If the client is already in a room, update via the client. // Otherwise, update the playlist directly in preparation for it to be submitted to the API on match creation. if (client.Room != null) @@ -89,30 +51,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } else { - playlist.Clear(); - playlist.Add(item); + Playlist.Clear(); + Playlist.Add(item); this.Exit(); } - - return true; - } - - public override bool OnExiting(IScreen next) - { - if (!itemSelected) - { - Beatmap.Value = initialBeatmap; - Ruleset.Value = initialRuleset; - Mods.Value = initialMods; - } - - return base.OnExiting(next); } protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); - protected override ModSelectOverlay CreateModSelectOverlay() => new ModSelectOverlay(isValidMod); - - private bool isValidMod(Mod mod) => !(mod is ModAutoplay) && (mod as MultiMod)?.Mods.Any(mm => mm is ModAutoplay) != true; + protected override bool IsValidFreeMod(Mod mod) => base.IsValidFreeMod(mod) && !(mod is ModTimeRamp) && !(mod is ModRateAdjust) && !mod.RequiresConfiguration; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index c071637b9b..061e3b4d3f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.Linq; @@ -13,12 +14,16 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; +using osu.Game.Screens.Play.HUD; using osu.Game.Users; +using osuTK; using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.ParticipantsList; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -36,7 +41,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } + private ModSelectOverlay userModsSelectOverlay; private MultiplayerMatchSettingsOverlay settingsOverlay; + private Drawable userModsSection; private IBindable isConnected; @@ -54,7 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [BackgroundDependencyLoader] private void load() { - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { mainContent = new GridContainer { @@ -90,18 +97,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }, new Drawable[] { - new GridContainer + new Container { RelativeSizeAxes = Axes.Both, - Content = new[] + Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, + Child = new GridContainer { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - new Container + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), + new Dimension(), + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new GridContainer + // Main left column + new GridContainer { RelativeSizeAxes = Axes.Both, RowDimensions = new[] @@ -119,19 +133,62 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }, } } - } - }, - new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 5 }, - Children = new Drawable[] + }, + // Spacer + null, + // Main right column + new FillFlowContainer { - new OverlinedHeader("Beatmap"), - new BeatmapSelectionControl { RelativeSizeAxes = Axes.X } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new OverlinedHeader("Beatmap"), + new BeatmapSelectionControl { RelativeSizeAxes = Axes.X } + } + }, + userModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new PurpleTriangleButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = () => userModsSelectOverlay.Show() + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + DisplayUnrankedText = false, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + } + } + } + } } } } @@ -162,7 +219,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { new MultiplayerMatchFooter { - SelectedItem = { BindTarget = SelectedItem }, OnReadyClick = onReadyClick } } @@ -173,12 +229,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer new Dimension(GridSizeMode.AutoSize), } }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Child = userModsSelectOverlay = new UserModSelectOverlay + { + SelectedMods = { BindTarget = UserMods }, + IsValidMod = _ => false + } + }, settingsOverlay = new MultiplayerMatchSettingsOverlay { RelativeSizeAxes = Axes.Both, State = { Value = client.Room == null ? Visibility.Visible : Visibility.Hidden } } - }; + }); if (client.Room == null) { @@ -199,6 +267,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); Playlist.BindCollectionChanged(onPlaylistChanged, true); + UserMods.BindValueChanged(onUserModsChanged); client.LoadRequested += onLoadRequested; @@ -218,10 +287,39 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return true; } + if (userModsSelectOverlay.State.Value == Visibility.Visible) + { + userModsSelectOverlay.Hide(); + return true; + } + return base.OnBackButton(); } - private void onPlaylistChanged(object sender, NotifyCollectionChangedEventArgs e) => SelectedItem.Value = Playlist.FirstOrDefault(); + private void onPlaylistChanged(object sender, NotifyCollectionChangedEventArgs e) + { + SelectedItem.Value = Playlist.FirstOrDefault(); + + if (SelectedItem.Value?.AllowedMods.Any() != true) + { + userModsSection.Hide(); + userModsSelectOverlay.Hide(); + userModsSelectOverlay.IsValidMod = _ => false; + } + else + { + userModsSection.Show(); + userModsSelectOverlay.IsValidMod = m => SelectedItem.Value.AllowedMods.Any(a => a.GetType() == m.GetType()); + } + } + + private void onUserModsChanged(ValueChangedEvent> mods) + { + if (client.Room == null) + return; + + client.ChangeUserMods(mods.NewValue); + } private void onReadyClick() { @@ -274,5 +372,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client != null) client.LoadRequested -= onLoadRequested; } + + private class UserModSelectOverlay : ModSelectOverlay + { + public UserModSelectOverlay() + { + CustomiseButton.Alpha = 0; + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index b5533f49cc..2983d1268d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,6 +16,8 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; @@ -29,6 +32,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private RulesetStore rulesets { get; set; } + + private ModDisplay userModsDisplay; private StateDisplay userStateDisplay; private SpriteIcon crown; @@ -121,6 +128,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Right = 70 }, + Child = userModsDisplay = new ModDisplay + { + Scale = new Vector2(0.5f), + ExpansionMode = ExpansionMode.AlwaysContracted, + DisplayUnrankedText = false, + } + }, userStateDisplay = new StateDisplay { Anchor = Anchor.CentreRight, @@ -142,7 +162,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; + var ruleset = rulesets.GetRuleset(Room.Settings.RulesetID).CreateInstance(); + userStateDisplay.Status = User.State; + userModsDisplay.Current.Value = User.Mods.Select(m => m.ToMod(ruleset)).ToList(); if (Room.Host?.Equals(User) == true) crown.FadeIn(fade_time); diff --git a/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs b/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs index b7ee84eb9e..aabeafe460 100644 --- a/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs +++ b/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs @@ -47,26 +47,21 @@ namespace osu.Game.Screens.OnlinePlay private void endOperationWithKnownLease(LeasedBindable lease) { - if (lease != leasedInProgress) - return; - // for extra safety, marshal the end of operation back to the update thread if necessary. Scheduler.Add(() => { - leasedInProgress?.Return(); + if (lease != leasedInProgress) + return; + + // UnbindAll() is purposefully used instead of Return() - the two do roughly the same thing, with one difference: + // the former won't throw if the lease has already been returned before. + // this matters because framework can unbind the lease via the internal UnbindAllBindables(), which is not always detectable + // (it is in the case of disposal, but not in the case of screen exit - at least not cleanly). + leasedInProgress?.UnbindAll(); leasedInProgress = null; }, false); } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - // base call does an UnbindAllBindables(). - // clean up the leased reference here so that it doesn't get returned twice. - leasedInProgress = null; - } - private class OngoingOperation : IDisposable { private readonly OngoingOperationTracker tracker; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs new file mode 100644 index 0000000000..0baa663578 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -0,0 +1,154 @@ +// 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 Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Online.Rooms; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select; +using osu.Game.Utils; + +namespace osu.Game.Screens.OnlinePlay +{ + public abstract class OnlinePlaySongSelect : SongSelect, IOnlinePlaySubScreen + { + public string ShortTitle => "song selection"; + + public override string Title => ShortTitle.Humanize(); + + public override bool AllowEditing => false; + + [Resolved(typeof(Room), nameof(Room.Playlist))] + protected BindableList Playlist { get; private set; } + + private readonly Bindable> freeMods = new Bindable>(Array.Empty()); + private readonly FreeModSelectOverlay freeModSelectOverlay; + + private WorkingBeatmap initialBeatmap; + private RulesetInfo initialRuleset; + private IReadOnlyList initialMods; + private bool itemSelected; + + protected OnlinePlaySongSelect() + { + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + + freeModSelectOverlay = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = freeMods }, + IsValidMod = IsValidFreeMod, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + initialBeatmap = Beatmap.Value; + initialRuleset = Ruleset.Value; + initialMods = Mods.Value.ToList(); + + FooterPanels.Add(freeModSelectOverlay); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. + // Similarly, freeMods is currently empty but should only contain the allowed mods. + Mods.Value = Playlist.FirstOrDefault()?.RequiredMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); + freeMods.Value = Playlist.FirstOrDefault()?.AllowedMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); + + Ruleset.BindValueChanged(onRulesetChanged); + } + + private void onRulesetChanged(ValueChangedEvent ruleset) + { + freeMods.Value = Array.Empty(); + } + + protected sealed override bool OnStart() + { + itemSelected = true; + + var item = new PlaylistItem(); + + item.Beatmap.Value = Beatmap.Value.BeatmapInfo; + item.Ruleset.Value = Ruleset.Value; + + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); + + item.AllowedMods.Clear(); + item.AllowedMods.AddRange(freeMods.Value.Select(m => m.CreateCopy())); + + SelectItem(item); + return true; + } + + /// + /// Invoked when the user has requested a selection of a beatmap. + /// + /// The resultant . This item has not yet been added to the 's. + protected abstract void SelectItem(PlaylistItem item); + + public override bool OnBackButton() + { + if (freeModSelectOverlay.State.Value == Visibility.Visible) + { + freeModSelectOverlay.Hide(); + return true; + } + + return base.OnBackButton(); + } + + public override bool OnExiting(IScreen next) + { + if (!itemSelected) + { + Beatmap.Value = initialBeatmap; + Ruleset.Value = initialRuleset; + Mods.Value = initialMods; + } + + return base.OnExiting(next); + } + + protected override ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay + { + IsValidMod = IsValidMod + }; + + protected override IEnumerable<(FooterButton, OverlayContainer)> CreateFooterButtons() + { + var buttons = base.CreateFooterButtons().ToList(); + buttons.Insert(buttons.FindIndex(b => b.Item1 is FooterButtonMods) + 1, (new FooterButtonFreeMods { Current = freeMods }, freeModSelectOverlay)); + return buttons; + } + + /// + /// Checks whether a given is valid for global selection. + /// + /// The to check. + /// Whether is a valid mod for online play. + protected virtual bool IsValidMod(Mod mod) => mod.HasImplementation && !ModUtils.FlattenMod(mod).Any(m => m is ModAutoplay); + + /// + /// Checks whether a given is valid for per-player free-mod selection. + /// + /// The to check. + /// Whether is a selectable free-mod. + protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs index 56d3143f59..2a1efbc040 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs @@ -183,7 +183,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Child = new GridContainer { RelativeSizeAxes = Axes.X, - Height = 300, + Height = 500, Content = new[] { new Drawable[] diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs index edee8e571a..9ac1fe1722 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; @@ -15,6 +16,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved(typeof(Room), nameof(Room.EndDate))] private Bindable endDate { get; set; } + [Resolved] + private IBindable gameBeatmap { get; set; } + public PlaylistsReadyButton() { Text = "Start"; @@ -32,7 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { base.Update(); - Enabled.Value = endDate.Value != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(GameBeatmap.Value.Track.Length) < endDate.Value; + Enabled.Value = endDate.Value != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(gameBeatmap.Value.Track.Length) < endDate.Value; } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 22580f0537..88731a10bc 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -13,7 +13,6 @@ using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; -using osu.Game.Screens.Select; using osu.Game.Users; using Footer = osu.Game.Screens.OnlinePlay.Match.Components.Footer; @@ -44,7 +43,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load() { - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { mainContent = new GridContainer { @@ -175,7 +174,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Footer { OnStart = onStart, - SelectedItem = { BindTarget = SelectedItem } } } }, @@ -188,10 +186,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists settingsOverlay = new PlaylistsMatchSettingsOverlay { RelativeSizeAxes = Axes.Both, - EditPlaylist = () => this.Push(new MatchSongSelect()), + EditPlaylist = () => this.Push(new PlaylistsSongSelect()), State = { Value = roomId.Value == null ? Visibility.Visible : Visibility.Hidden } } - }; + }); if (roomId.Value == null) { diff --git a/osu.Game/Screens/Select/MatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs similarity index 58% rename from osu.Game/Screens/Select/MatchSongSelect.cs rename to osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index ed47b5d5ac..0e8db6dfe5 100644 --- a/osu.Game/Screens/Select/MatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -1,48 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Linq; -using Humanizer; using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Online.Rooms; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.Select; -namespace osu.Game.Screens.Select +namespace osu.Game.Screens.OnlinePlay.Playlists { - public class MatchSongSelect : SongSelect, IOnlinePlaySubScreen + public class PlaylistsSongSelect : OnlinePlaySongSelect { - public Action Selected; - - public string ShortTitle => "song selection"; - public override string Title => ShortTitle.Humanize(); - - public override bool AllowEditing => false; - - [Resolved(typeof(Room), nameof(Room.Playlist))] - protected BindableList Playlist { get; private set; } - [Resolved] private BeatmapManager beatmaps { get; set; } - public MatchSongSelect() - { - Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; - } - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea { CreateNewItem = createNewItem }; - protected override bool OnStart() + protected override void SelectItem(PlaylistItem item) { switch (Playlist.Count) { @@ -56,8 +35,6 @@ namespace osu.Game.Screens.Select } this.Exit(); - - return true; } private void createNewItem() @@ -80,9 +57,5 @@ namespace osu.Game.Screens.Select item.RequiredMods.Clear(); item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); } - - protected override ModSelectOverlay CreateModSelectOverlay() => new ModSelectOverlay(isValidMod); - - private bool isValidMod(Mod mod) => !(mod is ModAutoplay) && (mod as MultiMod)?.Mods.Any(mm => mm is ModAutoplay) != true; } } diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index 689a11166a..ee13ebda44 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -28,19 +28,16 @@ namespace osu.Game.Screens.Select private readonly List overlays = new List(); - /// THe button to be added. + /// The button to be added. /// The to be toggled by this button. public void AddButton(FooterButton button, OverlayContainer overlay) { - overlays.Add(overlay); - button.Action = () => showOverlay(overlay); + if (overlay != null) + { + overlays.Add(overlay); + button.Action = () => showOverlay(overlay); + } - AddButton(button); - } - - /// Button to be added. - public void AddButton(FooterButton button) - { button.Hovered = updateModeLight; button.HoverLost = updateModeLight; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 4fca77a176..ed6e0a1028 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -263,9 +263,8 @@ namespace osu.Game.Screens.Select if (Footer != null) { - Footer.AddButton(new FooterButtonMods { Current = Mods }, ModSelect); - Footer.AddButton(new FooterButtonRandom { Action = triggerRandom }); - Footer.AddButton(new FooterButtonOptions(), BeatmapOptions); + foreach (var (button, overlay) in CreateFooterButtons()) + Footer.AddButton(button, overlay); BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo)); @@ -301,7 +300,18 @@ namespace osu.Game.Screens.Select } } - protected virtual ModSelectOverlay CreateModSelectOverlay() => new ModSelectOverlay(); + /// + /// Creates the buttons to be displayed in the footer. + /// + /// A set of and an optional which the button opens when pressed. + protected virtual IEnumerable<(FooterButton, OverlayContainer)> CreateFooterButtons() => new (FooterButton, OverlayContainer)[] + { + (new FooterButtonMods { Current = Mods }, ModSelect), + (new FooterButtonRandom { Action = triggerRandom }, null), + (new FooterButtonOptions(), BeatmapOptions) + }; + + protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 7fbc770351..d0d41e56c3 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -3,6 +3,7 @@ #nullable enable +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using osu.Framework.Bindables; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer @@ -122,6 +124,21 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public void ChangeUserMods(int userId, IEnumerable newMods) + => ChangeUserMods(userId, newMods.Select(m => new APIMod(m)).ToList()); + + public void ChangeUserMods(int userId, IEnumerable newMods) + { + Debug.Assert(Room != null); + ((IMultiplayerClient)this).UserModsChanged(userId, newMods.ToList()); + } + + public override Task ChangeUserMods(IEnumerable newMods) + { + ChangeUserMods(api.LocalUser.Value.Id, newMods); + return Task.CompletedTask; + } + public override Task StartMatch() { Debug.Assert(Room != null); diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs new file mode 100644 index 0000000000..c12b5a9fd4 --- /dev/null +++ b/osu.Game/Utils/ModUtils.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Game.Rulesets.Mods; + +#nullable enable + +namespace osu.Game.Utils +{ + /// + /// A set of utilities to handle combinations. + /// + public static class ModUtils + { + /// + /// Checks that all s are compatible with each-other, and that all appear within a set of allowed types. + /// + /// + /// The allowed types must contain exact types for the respective s to be allowed. + /// + /// The s to check. + /// The set of allowed types. + /// Whether all s are compatible with each-other and appear in the set of allowed types. + public static bool CheckCompatibleSetAndAllowed(IEnumerable combination, IEnumerable allowedTypes) + { + // Prevent multiple-enumeration. + var combinationList = combination as ICollection ?? combination.ToArray(); + return CheckCompatibleSet(combinationList, out _) && CheckAllowed(combinationList, allowedTypes); + } + + /// + /// Checks that all s in a combination are compatible with each-other. + /// + /// The combination to check. + /// Whether all s in the combination are compatible with each-other. + public static bool CheckCompatibleSet(IEnumerable combination) + => CheckCompatibleSet(combination, out _); + + /// + /// Checks that all s in a combination are compatible with each-other. + /// + /// The combination to check. + /// Any invalid mods in the set. + /// Whether all s in the combination are compatible with each-other. + public static bool CheckCompatibleSet(IEnumerable combination, [NotNullWhen(false)] out List? invalidMods) + { + combination = FlattenMods(combination).ToArray(); + invalidMods = null; + + foreach (var mod in combination) + { + foreach (var type in mod.IncompatibleMods) + { + foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m))) + { + invalidMods ??= new List(); + invalidMods.Add(invalid); + } + } + } + + return invalidMods == null; + } + + /// + /// Checks that all s in a combination appear within a set of allowed types. + /// + /// + /// The set of allowed types must contain exact types for the respective s to be allowed. + /// + /// The combination to check. + /// The set of allowed types. + /// Whether all s in the combination are allowed. + public static bool CheckAllowed(IEnumerable combination, IEnumerable allowedTypes) + { + var allowedSet = new HashSet(allowedTypes); + + return combination.SelectMany(FlattenMod) + .All(m => allowedSet.Contains(m.GetType())); + } + + /// + /// Check the provided combination of mods are valid for a local gameplay session. + /// + /// The mods to check. + /// Invalid mods, if any were found. Can be null if all mods were valid. + /// Whether the input mods were all valid. If false, will contain all invalid entries. + public static bool CheckValidForGameplay(IEnumerable mods, out List? invalidMods) + { + mods = mods.ToArray(); + + CheckCompatibleSet(mods, out invalidMods); + + foreach (var mod in mods) + { + if (mod.Type == ModType.System || !mod.HasImplementation || mod is MultiMod) + { + invalidMods ??= new List(); + invalidMods.Add(mod); + } + } + + return invalidMods == null; + } + + /// + /// Flattens a set of s, returning a new set with all s removed. + /// + /// The set of s to flatten. + /// The new set, containing all s in recursively with all s removed. + public static IEnumerable FlattenMods(IEnumerable mods) => mods.SelectMany(FlattenMod); + + /// + /// Flattens a , returning a set of s in-place of any s. + /// + /// The to flatten. + /// A set of singular "flattened" s + public static IEnumerable FlattenMod(Mod mod) + { + if (mod is MultiMod multi) + { + foreach (var m in multi.Mods.SelectMany(FlattenMod)) + yield return m; + } + else + yield return mod; + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index e2b506e187..f866b232d8 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -20,11 +20,13 @@ - - - + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index dc3527c687..22d104f2e1 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -80,6 +80,9 @@ + + +