diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6ba6ae82c8..dd53eefd23 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -13,6 +13,24 @@ "commands": [ "dotnet-format" ] + }, + "jetbrains.resharper.globaltools": { + "version": "2020.2.4", + "commands": [ + "jb" + ] + }, + "nvika": { + "version": "2.0.0", + "commands": [ + "nvika" + ] + }, + "codefilesanity": { + "version": "15.0.0", + "commands": [ + "CodeFileSanity" + ] } } } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index aa77d4f055..a70e5ac3a9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,7 +9,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Desktop", "-p:GenerateFullPaths=true", "-m", @@ -24,7 +23,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Desktop", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -40,7 +38,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Tests", "-p:GenerateFullPaths=true", "-m", @@ -55,7 +52,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Tests", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -71,7 +67,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Tournament.Tests", "-p:GenerateFullPaths=true", "-m", @@ -86,7 +81,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Tournament.Tests", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -102,7 +96,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Benchmarks", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -111,16 +104,6 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, - { - "label": "Restore (netcoreapp3.1)", - "type": "shell", - "command": "dotnet", - "args": [ - "restore", - "build/Desktop.proj" - ], - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/README.md b/README.md index 86c42dae12..c9443ba063 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,6 @@ git pull Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing). - Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln.` This will allow access to template run configurations. -- Visual Studio Code users must run the `Restore` task before any build attempt. You can also build and run *osu!* from the command-line with a single command: diff --git a/build/InspectCode.cake b/build/InspectCode.cake index c8f4f37c94..6836d9071b 100644 --- a/build/InspectCode.cake +++ b/build/InspectCode.cake @@ -1,7 +1,4 @@ #addin "nuget:?package=CodeFileSanity&version=0.0.36" -#addin "nuget:?package=JetBrains.ReSharper.CommandLineTools&version=2020.1.3" -#tool "nuget:?package=NVika.MSBuild&version=1.0.1" -var nVikaToolPath = GetFiles("./tools/NVika.MSBuild.*/tools/NVika.exe").First(); /////////////////////////////////////////////////////////////////////////////// // ARGUMENTS @@ -18,23 +15,15 @@ var desktopSlnf = rootDirectory.CombineWithFilePath("osu.Desktop.slnf"); // TASKS /////////////////////////////////////////////////////////////////////////////// -// windows only because both inspectcode and nvika depend on net45 Task("InspectCode") - .WithCriteria(IsRunningOnWindows()) .Does(() => { - InspectCode(desktopSlnf, new InspectCodeSettings { - CachesHome = "inspectcode", - OutputFile = "inspectcodereport.xml", - ArgumentCustomization = arg => { - if (AppVeyor.IsRunningOnAppVeyor) // Don't flood CI output - arg.Append("--verbosity:WARN"); - return arg; - }, - }); + var inspectcodereport = "inspectcodereport.xml"; + var cacheDir = "inspectcode"; + var verbosity = AppVeyor.IsRunningOnAppVeyor ? "WARN" : "INFO"; // Don't flood CI output - int returnCode = StartProcess(nVikaToolPath, $@"parsereport ""inspectcodereport.xml"" --treatwarningsaserrors"); - if (returnCode != 0) - throw new Exception($"inspectcode failed with return code {returnCode}"); + DotNetCoreTool(rootDirectory.FullPath, + "jb", $@"inspectcode ""{desktopSlnf}"" --output=""{inspectcodereport}"" --caches-home=""{cacheDir}"" --verbosity={verbosity}"); + DotNetCoreTool(rootDirectory.FullPath, "nvika", $@"parsereport ""{inspectcodereport}"" --treatwarningsaserrors"); }); Task("CodeFileSanity") diff --git a/osu.Android.props b/osu.Android.props index 6e3d5eec1f..6dab6edc5e 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,6 +52,6 @@ - + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index b17611f23f..0feab9a717 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -139,7 +139,7 @@ namespace osu.Desktop // SDL2 DesktopWindow case DesktopWindow desktopWindow: - desktopWindow.CursorState.Value |= CursorState.Hidden; + desktopWindow.CursorState |= CursorState.Hidden; desktopWindow.SetIconFromStream(iconStream); desktopWindow.Title = Name; desktopWindow.DragDrop += f => fileDrop(new[] { f }); diff --git a/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json index 2c915a31b7..d8feacc8a7 100644 --- a/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json @@ -9,7 +9,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Catch.Tests.csproj", "-p:GenerateFullPaths=true", "-m", @@ -24,7 +23,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Catch.Tests.csproj", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -33,15 +31,6 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, - { - "label": "Restore", - "type": "shell", - "command": "dotnet", - "args": [ - "restore" - ], - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs index 04e6dea376..eae07daa3d 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs @@ -12,17 +12,32 @@ namespace osu.Game.Rulesets.Catch.Tests [TestFixture] public class CatchLegacyModConversionTest : LegacyModConversionTest { - [TestCase(LegacyMods.Easy, new[] { typeof(CatchModEasy) })] - [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(CatchModHardRock), typeof(CatchModDoubleTime) })] - [TestCase(LegacyMods.DoubleTime, new[] { typeof(CatchModDoubleTime) })] - [TestCase(LegacyMods.Nightcore, new[] { typeof(CatchModNightcore) })] + private static readonly object[][] catch_mod_mapping = + { + new object[] { LegacyMods.NoFail, new[] { typeof(CatchModNoFail) } }, + new object[] { LegacyMods.Easy, new[] { typeof(CatchModEasy) } }, + new object[] { LegacyMods.Hidden, new[] { typeof(CatchModHidden) } }, + new object[] { LegacyMods.HardRock, new[] { typeof(CatchModHardRock) } }, + new object[] { LegacyMods.SuddenDeath, new[] { typeof(CatchModSuddenDeath) } }, + new object[] { LegacyMods.DoubleTime, new[] { typeof(CatchModDoubleTime) } }, + new object[] { LegacyMods.Relax, new[] { typeof(CatchModRelax) } }, + new object[] { LegacyMods.HalfTime, new[] { typeof(CatchModHalfTime) } }, + new object[] { LegacyMods.Nightcore, new[] { typeof(CatchModNightcore) } }, + new object[] { LegacyMods.Flashlight, new[] { typeof(CatchModFlashlight) } }, + new object[] { LegacyMods.Autoplay, new[] { typeof(CatchModAutoplay) } }, + new object[] { LegacyMods.Perfect, new[] { typeof(CatchModPerfect) } }, + new object[] { LegacyMods.Cinema, new[] { typeof(CatchModCinema) } }, + new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(CatchModHardRock), typeof(CatchModDoubleTime) } } + }; + + [TestCaseSource(nameof(catch_mod_mapping))] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(CatchModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(CatchModNightcore) })] - [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(CatchModFlashlight), typeof(CatchModNightcore) })] - [TestCase(LegacyMods.Perfect, new[] { typeof(CatchModPerfect) })] - [TestCase(LegacyMods.SuddenDeath, new[] { typeof(CatchModSuddenDeath) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(CatchModPerfect) })] - [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(CatchModDoubleTime), typeof(CatchModPerfect) })] - public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + + [TestCaseSource(nameof(catch_mod_mapping))] + public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new CatchRuleset(); } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 385d8ed7fa..89063319d6 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -18,71 +18,42 @@ namespace osu.Game.Rulesets.Catch.Tests base.LoadComplete(); foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) - AddStep($"show {rep}", () => SetContents(() => createDrawable(rep))); + AddStep($"show {rep}", () => SetContents(() => createDrawableFruit(rep))); AddStep("show droplet", () => SetContents(() => createDrawableDroplet())); AddStep("show tiny droplet", () => SetContents(createDrawableTinyDroplet)); foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) - AddStep($"show hyperdash {rep}", () => SetContents(() => createDrawable(rep, true))); + AddStep($"show hyperdash {rep}", () => SetContents(() => createDrawableFruit(rep, true))); AddStep("show hyperdash droplet", () => SetContents(() => createDrawableDroplet(true))); } - private Drawable createDrawableTinyDroplet() + private Drawable createDrawableFruit(FruitVisualRepresentation rep, bool hyperdash = false) => + setProperties(new DrawableFruit(new TestCatchFruit(rep)), hyperdash); + + private Drawable createDrawableDroplet(bool hyperdash = false) => setProperties(new DrawableDroplet(new Droplet()), hyperdash); + + private Drawable createDrawableTinyDroplet() => setProperties(new DrawableTinyDroplet(new TinyDroplet())); + + private DrawableCatchHitObject setProperties(DrawableCatchHitObject d, bool hyperdash = false) { - var droplet = new TestCatchTinyDroplet - { - Scale = 1.5f, - }; + var hitObject = d.HitObject; + hitObject.StartTime = 1000000000000; + hitObject.Scale = 1.5f; - return new DrawableTinyDroplet(droplet) - { - Anchor = Anchor.Centre, - RelativePositionAxes = Axes.None, - Position = Vector2.Zero, - Alpha = 1, - LifetimeStart = double.NegativeInfinity, - LifetimeEnd = double.PositiveInfinity, - }; - } + if (hyperdash) + hitObject.HyperDashTarget = new Banana(); - private Drawable createDrawableDroplet(bool hyperdash = false) - { - var droplet = new TestCatchDroplet + d.Anchor = Anchor.Centre; + d.RelativePositionAxes = Axes.None; + d.Position = Vector2.Zero; + d.HitObjectApplied += _ => { - Scale = 1.5f, - HyperDashTarget = hyperdash ? new Banana() : null - }; - - return new DrawableDroplet(droplet) - { - Anchor = Anchor.Centre, - RelativePositionAxes = Axes.None, - Position = Vector2.Zero, - Alpha = 1, - LifetimeStart = double.NegativeInfinity, - LifetimeEnd = double.PositiveInfinity, - }; - } - - private Drawable createDrawable(FruitVisualRepresentation rep, bool hyperdash = false) - { - Fruit fruit = new TestCatchFruit(rep) - { - Scale = 1.5f, - HyperDashTarget = hyperdash ? new Banana() : null - }; - - return new DrawableFruit(fruit) - { - Anchor = Anchor.Centre, - RelativePositionAxes = Axes.None, - Position = Vector2.Zero, - Alpha = 1, - LifetimeStart = double.NegativeInfinity, - LifetimeEnd = double.PositiveInfinity, + d.LifetimeStart = double.NegativeInfinity; + d.LifetimeEnd = double.PositiveInfinity; }; + return d; } public class TestCatchFruit : Fruit @@ -90,26 +61,9 @@ namespace osu.Game.Rulesets.Catch.Tests public TestCatchFruit(FruitVisualRepresentation rep) { VisualRepresentation = rep; - StartTime = 1000000000000; } public override FruitVisualRepresentation VisualRepresentation { get; } } - - public class TestCatchDroplet : Droplet - { - public TestCatchDroplet() - { - StartTime = 1000000000000; - } - } - - public class TestCatchTinyDroplet : TinyDroplet - { - public TestCatchTinyDroplet() - { - StartTime = 1000000000000; - } - } } } diff --git a/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json index ca03924c70..323110b605 100644 --- a/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json @@ -9,7 +9,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Mania.Tests.csproj", "-p:GenerateFullPaths=true", "-m", @@ -24,7 +23,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Mania.Tests.csproj", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -33,15 +31,6 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, - { - "label": "Restore", - "type": "shell", - "command": "dotnet", - "args": [ - "restore" - ], - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs index b22687a0a7..a28c188051 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs @@ -12,19 +12,44 @@ namespace osu.Game.Rulesets.Mania.Tests [TestFixture] public class ManiaLegacyModConversionTest : LegacyModConversionTest { - [TestCase(LegacyMods.Easy, new[] { typeof(ManiaModEasy) })] - [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(ManiaModHardRock), typeof(ManiaModDoubleTime) })] - [TestCase(LegacyMods.DoubleTime, new[] { typeof(ManiaModDoubleTime) })] - [TestCase(LegacyMods.Nightcore, new[] { typeof(ManiaModNightcore) })] + private static readonly object[][] mania_mod_mapping = + { + new object[] { LegacyMods.NoFail, new[] { typeof(ManiaModNoFail) } }, + new object[] { LegacyMods.Easy, new[] { typeof(ManiaModEasy) } }, + new object[] { LegacyMods.Hidden, new[] { typeof(ManiaModHidden) } }, + new object[] { LegacyMods.HardRock, new[] { typeof(ManiaModHardRock) } }, + new object[] { LegacyMods.SuddenDeath, new[] { typeof(ManiaModSuddenDeath) } }, + new object[] { LegacyMods.DoubleTime, new[] { typeof(ManiaModDoubleTime) } }, + new object[] { LegacyMods.HalfTime, new[] { typeof(ManiaModHalfTime) } }, + new object[] { LegacyMods.Nightcore, new[] { typeof(ManiaModNightcore) } }, + new object[] { LegacyMods.Flashlight, new[] { typeof(ManiaModFlashlight) } }, + new object[] { LegacyMods.Autoplay, new[] { typeof(ManiaModAutoplay) } }, + new object[] { LegacyMods.Perfect, new[] { typeof(ManiaModPerfect) } }, + new object[] { LegacyMods.Key4, new[] { typeof(ManiaModKey4) } }, + new object[] { LegacyMods.Key5, new[] { typeof(ManiaModKey5) } }, + new object[] { LegacyMods.Key6, new[] { typeof(ManiaModKey6) } }, + new object[] { LegacyMods.Key7, new[] { typeof(ManiaModKey7) } }, + new object[] { LegacyMods.Key8, new[] { typeof(ManiaModKey8) } }, + new object[] { LegacyMods.FadeIn, new[] { typeof(ManiaModFadeIn) } }, + new object[] { LegacyMods.Random, new[] { typeof(ManiaModRandom) } }, + new object[] { LegacyMods.Cinema, new[] { typeof(ManiaModCinema) } }, + new object[] { LegacyMods.Key9, new[] { typeof(ManiaModKey9) } }, + new object[] { LegacyMods.KeyCoop, new[] { typeof(ManiaModDualStages) } }, + new object[] { LegacyMods.Key1, new[] { typeof(ManiaModKey1) } }, + new object[] { LegacyMods.Key3, new[] { typeof(ManiaModKey3) } }, + new object[] { LegacyMods.Key2, new[] { typeof(ManiaModKey2) } }, + new object[] { LegacyMods.Mirror, new[] { typeof(ManiaModMirror) } }, + new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(ManiaModHardRock), typeof(ManiaModDoubleTime) } } + }; + + [TestCaseSource(nameof(mania_mod_mapping))] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(ManiaModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(ManiaModNightcore) })] - [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(ManiaModFlashlight), typeof(ManiaModNightcore) })] - [TestCase(LegacyMods.Perfect, new[] { typeof(ManiaModPerfect) })] - [TestCase(LegacyMods.SuddenDeath, new[] { typeof(ManiaModSuddenDeath) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(ManiaModPerfect) })] - [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(ManiaModDoubleTime), typeof(ManiaModPerfect) })] - [TestCase(LegacyMods.Random | LegacyMods.SuddenDeath, new[] { typeof(ManiaModRandom), typeof(ManiaModSuddenDeath) })] - [TestCase(LegacyMods.Flashlight | LegacyMods.Mirror, new[] { typeof(ManiaModFlashlight), typeof(ManiaModMirror) })] - public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + + [TestCaseSource(nameof(mania_mod_mapping))] + public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new ManiaRuleset(); } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs index d24c81dac6..96444fd316 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning /// public abstract class ManiaHitObjectTestScene : ManiaSkinnableTestScene { - [BackgroundDependencyLoader] - private void load() + [SetUp] + public void SetUp() => Schedule(() => { SetContents(() => new FillFlowContainer { @@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning }, } }); - } + }); protected abstract DrawableManiaHitObject CreateHitObject(); } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs index a4d4ec50f8..dcb25f21ba 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs @@ -24,7 +24,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning if (hitWindows.IsHitResultAllowed(result)) { AddStep("Show " + result.GetDescription(), () => SetContents(() => - new DrawableManiaJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) + new DrawableManiaJudgement(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement()) + { + Type = result + }, null) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs index 9c4c2b3d5b..e88ff8e2ac 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; @@ -26,6 +27,18 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning }); } + [Test] + public void TestFadeOnMiss() + { + AddStep("miss tick", () => + { + foreach (var holdNote in holdNotes) + holdNote.ChildrenOfType().First().MissForcefully(); + }); + } + + private IEnumerable holdNotes => CreatedDrawables.SelectMany(d => d.ChildrenOfType()); + protected override DrawableManiaHitObject CreateHitObject() { var note = new HoldNote { Duration = 1000 }; diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index b92e042686..906f7382c5 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -119,6 +119,9 @@ namespace osu.Game.Rulesets.Mania if (mods.HasFlag(LegacyMods.Key9)) yield return new ManiaModKey9(); + if (mods.HasFlag(LegacyMods.KeyCoop)) + yield return new ManiaModDualStages(); + if (mods.HasFlag(LegacyMods.NoFail)) yield return new ManiaModNoFail(); @@ -173,13 +176,22 @@ namespace osu.Game.Rulesets.Mania value |= LegacyMods.Key9; break; + case ManiaModDualStages _: + value |= LegacyMods.KeyCoop; + break; + case ManiaModFadeIn _: value |= LegacyMods.FadeIn; + value &= ~LegacyMods.Hidden; // this is toggled on in the base call due to inheritance, but we don't want that. break; case ManiaModMirror _: value |= LegacyMods.Mirror; break; + + case ManiaModRandom _: + value |= LegacyMods.Random; + break; } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 59899637f9..a64cc6dc67 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -51,9 +51,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public double? HoldStartTime { get; private set; } /// - /// Whether the hold note has been released too early and shouldn't give full score for the release. + /// Time at which the hold note has been broken, i.e. released too early, resulting in a reduced score. /// - public bool HasBroken { get; private set; } + public double? HoldBrokenTime { get; private set; } /// /// Whether the hold note has been released potentially without having caused a break. @@ -238,7 +238,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables } if (Tail.Judged && !Tail.IsHit) - HasBroken = true; + HoldBrokenTime = Time.Current; } public bool OnPressed(ManiaAction action) @@ -298,7 +298,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // If the key has been released too early, the user should not receive full score for the release if (!Tail.IsHit) - HasBroken = true; + HoldBrokenTime = Time.Current; releaseTime = Time.Current; } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index c780c0836e..a4029e7893 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables ApplyResult(r => { // If the head wasn't hit or the hold note was broken, cap the max score to Meh. - if (result > HitResult.Meh && (!holdNote.Head.IsHit || holdNote.HasBroken)) + if (result > HitResult.Meh && (!holdNote.Head.IsHit || holdNote.HoldBrokenTime != null)) result = HitResult.Meh; r.Type = result; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs index f265419aa0..98931dceed 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs @@ -16,8 +16,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableHoldNoteTick : DrawableManiaHitObject { - public override bool DisplayResult => false; - /// /// References the time at which the user started holding the hold note. /// diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs index c0f0fcb4af..8902d82f33 100644 --- a/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs @@ -18,9 +18,17 @@ namespace osu.Game.Rulesets.Mania.Skinning { public class LegacyBodyPiece : LegacyManiaColumnElement { + private DrawableHoldNote holdNote; + private readonly IBindable direction = new Bindable(); private readonly IBindable isHitting = new Bindable(); + /// + /// Stores the start time of the fade animation that plays when any of the nested + /// hitobjects of the hold note are missed. + /// + private readonly Bindable missFadeTime = new Bindable(); + [CanBeNull] private Drawable bodySprite; @@ -38,6 +46,8 @@ namespace osu.Game.Rulesets.Mania.Skinning [BackgroundDependencyLoader] private void load(ISkinSource skin, IScrollingInfo scrollingInfo, DrawableHitObject drawableObject) { + holdNote = (DrawableHoldNote)drawableObject; + string imageName = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage)?.Value ?? $"mania-note{FallbackColumnIndex}L"; @@ -92,11 +102,26 @@ namespace osu.Game.Rulesets.Mania.Skinning InternalChild = bodySprite; direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(onDirectionChanged, true); - - var holdNote = (DrawableHoldNote)drawableObject; isHitting.BindTo(holdNote.IsHitting); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + direction.BindValueChanged(onDirectionChanged, true); isHitting.BindValueChanged(onIsHittingChanged, true); + missFadeTime.BindValueChanged(onMissFadeTimeChanged, true); + + holdNote.ApplyCustomUpdateState += applyCustomUpdateState; + applyCustomUpdateState(holdNote, holdNote.State.Value); + } + + private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state) + { + // ensure that the hold note is also faded out when the head/tail/any tick is missed. + if (state == ArmedState.Miss) + missFadeTime.Value ??= hitObject.HitStateUpdateTime; } private void onIsHittingChanged(ValueChangedEvent isHitting) @@ -158,10 +183,38 @@ namespace osu.Game.Rulesets.Mania.Skinning } } + private void onMissFadeTimeChanged(ValueChangedEvent missFadeTimeChange) + { + if (missFadeTimeChange.NewValue == null) + return; + + // this update could come from any nested object of the hold note (or even from an input). + // make sure the transforms are consistent across all affected parts. + using (BeginAbsoluteSequence(missFadeTimeChange.NewValue.Value)) + { + // colour and duration matches stable + // transforms not applied to entire hold note in order to not affect hit lighting + const double fade_duration = 60; + + holdNote.Head.FadeColour(Colour4.DarkGray, fade_duration); + holdNote.Tail.FadeColour(Colour4.DarkGray, fade_duration); + bodySprite?.FadeColour(Colour4.DarkGray, fade_duration); + } + } + + protected override void Update() + { + base.Update(); + missFadeTime.Value ??= holdNote.HoldBrokenTime; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + if (holdNote != null) + holdNote.ApplyCustomUpdateState -= applyCustomUpdateState; + lightContainer?.Expire(); } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index c28a1c13d8..9aabcc6699 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Mania.UI if (result.IsHit) hitPolicy.HandleHit(judgedObject); - if (!result.IsHit || !DisplayJudgements.Value) + if (!result.IsHit || !judgedObject.DisplayResult || !DisplayJudgements.Value) return; HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index d99f6cb8d3..a3dcd0e57f 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Mania.UI { @@ -19,22 +20,52 @@ namespace osu.Game.Rulesets.Mania.UI { } - [BackgroundDependencyLoader] - private void load() + protected override void ApplyMissAnimations() { - if (JudgementText != null) - JudgementText.Font = JudgementText.Font.With(size: 25); - } + if (!(JudgementBody.Drawable is DefaultManiaJudgementPiece)) + { + // this is temporary logic until mania's skin transformer returns IAnimatableJudgements + JudgementBody.ScaleTo(1.6f); + JudgementBody.ScaleTo(1, 100, Easing.In); - protected override double FadeInDuration => 50; + JudgementBody.MoveTo(Vector2.Zero); + JudgementBody.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + JudgementBody.RotateTo(0); + JudgementBody.RotateTo(40, 800, Easing.InQuint); + JudgementBody.FadeOutFromOne(800); + + LifetimeEnd = JudgementBody.LatestTransformEndTime; + } + + base.ApplyMissAnimations(); + } protected override void ApplyHitAnimations() { JudgementBody.ScaleTo(0.8f); JudgementBody.ScaleTo(1, 250, Easing.OutElastic); - JudgementBody.Delay(FadeInDuration).ScaleTo(0.75f, 250); - this.Delay(FadeInDuration).FadeOut(200); + JudgementBody.Delay(50) + .ScaleTo(0.75f, 250) + .FadeOut(200); + } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); + + private class DefaultManiaJudgementPiece : DefaultJudgementPiece + { + public DefaultManiaJudgementPiece(HitResult result) + : base(result) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + JudgementText.Font = JudgementText.Font.With(size: 25); + } } } } diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index e7a2de266d..3d7960ffe3 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -167,6 +167,10 @@ namespace osu.Game.Rulesets.Mania.UI if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; + // Tick judgements should not display text. + if (judgedObject is DrawableHoldNoteTick) + return; + judgements.Clear(false); judgements.Add(judgementPool.Get(j => { diff --git a/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json index 14ffbfb4ae..590bedb8b2 100644 --- a/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json @@ -9,7 +9,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Osu.Tests.csproj", "-p:GenerateFullPaths=true", "-m", @@ -24,7 +23,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Osu.Tests.csproj", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -33,15 +31,6 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, - { - "label": "Restore", - "type": "shell", - "command": "dotnet", - "args": [ - "restore" - ], - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs index 495f2738b5..51da5b85cd 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs @@ -12,18 +12,36 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class OsuLegacyModConversionTest : LegacyModConversionTest { - [TestCase(LegacyMods.Easy, new[] { typeof(OsuModEasy) })] - [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(OsuModHardRock), typeof(OsuModDoubleTime) })] - [TestCase(LegacyMods.DoubleTime, new[] { typeof(OsuModDoubleTime) })] - [TestCase(LegacyMods.Nightcore, new[] { typeof(OsuModNightcore) })] + private static readonly object[][] osu_mod_mapping = + { + new object[] { LegacyMods.NoFail, new[] { typeof(OsuModNoFail) } }, + new object[] { LegacyMods.Easy, new[] { typeof(OsuModEasy) } }, + new object[] { LegacyMods.TouchDevice, new[] { typeof(OsuModTouchDevice) } }, + new object[] { LegacyMods.Hidden, new[] { typeof(OsuModHidden) } }, + new object[] { LegacyMods.HardRock, new[] { typeof(OsuModHardRock) } }, + new object[] { LegacyMods.SuddenDeath, new[] { typeof(OsuModSuddenDeath) } }, + new object[] { LegacyMods.DoubleTime, new[] { typeof(OsuModDoubleTime) } }, + new object[] { LegacyMods.Relax, new[] { typeof(OsuModRelax) } }, + new object[] { LegacyMods.HalfTime, new[] { typeof(OsuModHalfTime) } }, + new object[] { LegacyMods.Nightcore, new[] { typeof(OsuModNightcore) } }, + new object[] { LegacyMods.Flashlight, new[] { typeof(OsuModFlashlight) } }, + new object[] { LegacyMods.Autoplay, new[] { typeof(OsuModAutoplay) } }, + new object[] { LegacyMods.SpunOut, new[] { typeof(OsuModSpunOut) } }, + new object[] { LegacyMods.Autopilot, new[] { typeof(OsuModAutopilot) } }, + new object[] { LegacyMods.Perfect, new[] { typeof(OsuModPerfect) } }, + new object[] { LegacyMods.Cinema, new[] { typeof(OsuModCinema) } }, + new object[] { LegacyMods.Target, new[] { typeof(OsuModTarget) } }, + new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(OsuModHardRock), typeof(OsuModDoubleTime) } } + }; + + [TestCaseSource(nameof(osu_mod_mapping))] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(OsuModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(OsuModNightcore) })] - [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(OsuModFlashlight), typeof(OsuModFlashlight) })] - [TestCase(LegacyMods.Perfect, new[] { typeof(OsuModPerfect) })] - [TestCase(LegacyMods.SuddenDeath, new[] { typeof(OsuModSuddenDeath) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(OsuModPerfect) })] - [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(OsuModDoubleTime), typeof(OsuModPerfect) })] - [TestCase(LegacyMods.SpunOut | LegacyMods.Easy, new[] { typeof(OsuModSpunOut), typeof(OsuModEasy) })] - public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + + [TestCaseSource(nameof(osu_mod_mapping))] + public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index 646f12f710..e4158d8f07 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -43,10 +43,8 @@ namespace osu.Game.Rulesets.Osu.Tests showResult(HitResult.Great); AddUntilStep("judgements shown", () => this.ChildrenOfType().Any()); - AddAssert("judgement body immediately visible", - () => this.ChildrenOfType().All(judgement => judgement.JudgementBody.Alpha == 1)); - AddAssert("hit lighting hidden", - () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha == 0)); + AddAssert("hit lighting has no transforms", () => this.ChildrenOfType().All(judgement => !judgement.Lighting.Transforms.Any())); + AddAssert("hit lighting hidden", () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha == 0)); } [Test] @@ -57,10 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests showResult(HitResult.Great); AddUntilStep("judgements shown", () => this.ChildrenOfType().Any()); - AddAssert("judgement body not immediately visible", - () => this.ChildrenOfType().All(judgement => judgement.JudgementBody.Alpha > 0 && judgement.JudgementBody.Alpha < 1)); - AddAssert("hit lighting shown", - () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha > 0)); + AddUntilStep("hit lighting shown", () => this.ChildrenOfType().Any(judgement => judgement.Lighting.Alpha > 0)); } private void showResult(HitResult result) @@ -89,7 +84,13 @@ namespace osu.Game.Rulesets.Osu.Tests Children = new Drawable[] { pool, - pool.Get(j => j.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null)).With(j => + pool.Get(j => j.Apply(new JudgementResult(new HitObject + { + StartTime = Time.Current + }, new Judgement()) + { + Type = result, + }, null)).With(j => { j.Anchor = Anchor.Centre; j.Origin = Anchor.Centre; @@ -106,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestDrawableOsuJudgement : DrawableOsuJudgement { public new SkinnableSprite Lighting => base.Lighting; - public new Container JudgementBody => base.JudgementBody; + public new SkinnableDrawable JudgementBody => base.JudgementBody; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs index 6c077eb214..fe67b63252 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -94,9 +95,19 @@ namespace osu.Game.Rulesets.Osu.Tests { addMultipleObjectsStep(); - AddStep("move hitobject", () => getObject(2).HitObject.Position = new Vector2(300, 100)); + AddStep("move hitobject", () => + { + var manualClock = new ManualClock(); + followPointRenderer.Clock = new FramedClock(manualClock); + + manualClock.CurrentTime = getObject(1).HitObject.StartTime; + followPointRenderer.UpdateSubTree(); + + getObject(2).HitObject.Position = new Vector2(300, 100); + }); assertGroups(); + assertDirections(); } [TestCase(0, 0)] // Start -> Start @@ -207,7 +218,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void assertGroups() { - AddAssert("has correct group count", () => followPointRenderer.Connections.Count == hitObjectContainer.Count); + AddAssert("has correct group count", () => followPointRenderer.Entries.Count == hitObjectContainer.Count); AddAssert("group endpoints are correct", () => { for (int i = 0; i < hitObjectContainer.Count; i++) @@ -215,10 +226,10 @@ namespace osu.Game.Rulesets.Osu.Tests DrawableOsuHitObject expectedStart = getObject(i); DrawableOsuHitObject expectedEnd = i < hitObjectContainer.Count - 1 ? getObject(i + 1) : null; - if (getGroup(i).Start != expectedStart.HitObject) + if (getEntry(i).Start != expectedStart.HitObject) throw new AssertionException($"Object {i} expected to be the start of group {i}."); - if (getGroup(i).End != expectedEnd?.HitObject) + if (getEntry(i).End != expectedEnd?.HitObject) throw new AssertionException($"Object {(expectedEnd == null ? "null" : i.ToString())} expected to be the end of group {i}."); } @@ -238,6 +249,12 @@ namespace osu.Game.Rulesets.Osu.Tests if (expectedEnd == null) continue; + var manualClock = new ManualClock(); + followPointRenderer.Clock = new FramedClock(manualClock); + + manualClock.CurrentTime = expectedStart.HitObject.StartTime; + followPointRenderer.UpdateSubTree(); + var points = getGroup(i).ChildrenOfType().ToArray(); if (points.Length == 0) continue; @@ -255,7 +272,9 @@ namespace osu.Game.Rulesets.Osu.Tests private DrawableOsuHitObject getObject(int index) => hitObjectContainer[index]; - private FollowPointConnection getGroup(int index) => followPointRenderer.Connections[index]; + private FollowPointLifetimeEntry getEntry(int index) => followPointRenderer.Entries[index]; + + private FollowPointConnection getGroup(int index) => followPointRenderer.ChildrenOfType().Single(c => c.Entry == getEntry(index)); private class TestHitObjectContainer : Container { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index 596bc06c68..1278a0ff2d 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -1,17 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osuTK; -using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Tests { @@ -38,13 +38,37 @@ namespace osu.Game.Rulesets.Osu.Tests } private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) + { + var drawable = createSingle(circleSize, auto, timeOffset, positionOffset); + + var playfield = new TestOsuPlayfield(); + playfield.Add(drawable); + return playfield; + } + + private Drawable testStream(float circleSize, bool auto = false) + { + var playfield = new TestOsuPlayfield(); + + Vector2 pos = new Vector2(-250, 0); + + for (int i = 0; i <= 1000; i += 100) + { + playfield.Add(createSingle(circleSize, auto, i, pos)); + pos.X += 50; + } + + return playfield; + } + + private TestDrawableHitCircle createSingle(float circleSize, bool auto, double timeOffset, Vector2? positionOffset) { positionOffset ??= Vector2.Zero; var circle = new HitCircle { StartTime = Time.Current + 1000 + timeOffset, - Position = positionOffset.Value, + Position = OsuPlayfield.BASE_SIZE / 4 + positionOffset.Value, }; circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); @@ -53,31 +77,14 @@ namespace osu.Game.Rulesets.Osu.Tests foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToDrawableHitObjects(new[] { drawable }); - return drawable; } protected virtual TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) => new TestDrawableHitCircle(circle, auto) { - Anchor = Anchor.Centre, Depth = depthIndex++ }; - private Drawable testStream(float circleSize, bool auto = false) - { - var container = new Container { RelativeSizeAxes = Axes.Both }; - - Vector2 pos = new Vector2(-250, 0); - - for (int i = 0; i <= 1000; i += 100) - { - container.Add(testSingle(circleSize, auto, i, pos)); - pos.X += 50; - } - - return container; - } - protected class TestDrawableHitCircle : DrawableHitCircle { private readonly bool auto; @@ -101,5 +108,13 @@ namespace osu.Game.Rulesets.Osu.Tests base.CheckForResult(userTriggered, timeOffset); } } + + protected class TestOsuPlayfield : OsuPlayfield + { + public TestOsuPlayfield() + { + RelativeSizeAxes = Axes.Both; + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index d692be89b2..7e973d0971 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Diagnostics; +using osu.Framework.Threading; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; @@ -10,6 +13,19 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneShaking : TestSceneHitCircle { + private readonly List scheduledTasks = new List(); + + protected override IBeatmap CreateBeatmapForSkinProvider() + { + // best way to run cleanup before a new step is run + foreach (var task in scheduledTasks) + task.Cancel(); + + scheduledTasks.Clear(); + + return base.CreateBeatmapForSkinProvider(); + } + protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) { var drawableHitObject = base.CreateDrawableHitCircle(circle, auto); @@ -17,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Tests Debug.Assert(drawableHitObject.HitObject.HitWindows != null); double delay = drawableHitObject.HitObject.StartTime - (drawableHitObject.HitObject.HitWindows.WindowFor(HitResult.Miss) + RNG.Next(0, 300)) - Time.Current; - Scheduler.AddDelayed(() => drawableHitObject.TriggerJudgement(), delay); + scheduledTasks.Add(Scheduler.AddDelayed(() => drawableHitObject.TriggerJudgement(), delay)); return drawableHitObject; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index fb1ebbb0d0..084af7dafe 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -1,20 +1,30 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSliderApplication : OsuTestScene { + [Resolved] + private SkinManager skinManager { get; set; } + [Test] public void TestApplyNewSlider() { @@ -50,6 +60,41 @@ namespace osu.Game.Rulesets.Osu.Tests }), null)); } + [Test] + public void TestBallTintChangedOnAccentChange() + { + DrawableSlider dho = null; + + AddStep("create slider", () => + { + var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.Info); + tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1"; + + Child = new SkinProvidingContainer(tintingSkin) + { + RelativeSizeAxes = Axes.Both, + Child = dho = new DrawableSlider(prepareObject(new Slider + { + Position = new Vector2(256, 192), + IndexInCurrentCombo = 0, + StartTime = Time.Current, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(150, 100), + new Vector2(300, 0), + }) + })) + }; + }); + + AddStep("set accent white", () => dho.AccentColour.Value = Color4.White); + AddAssert("ball is white", () => dho.ChildrenOfType().Single().AccentColour == Color4.White); + + AddStep("set accent red", () => dho.AccentColour.Value = Color4.Red); + AddAssert("ball is red", () => dho.ChildrenOfType().Single().AccentColour == Color4.Red); + } + private Slider prepareObject(Slider slider) { slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs index 0558dad30d..d7fbc7ac48 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Tests public class TestSceneSpinnerApplication : OsuTestScene { [Test] - public void TestApplyNewCircle() + public void TestApplyNewSpinner() { DrawableSpinner dho = null; @@ -23,18 +23,23 @@ namespace osu.Game.Rulesets.Osu.Tests { Position = new Vector2(256, 192), IndexInCurrentCombo = 0, - Duration = 0, + Duration = 500, })) { Clock = new FramedClock(new StopwatchClock()) }); + AddStep("rotate some", () => dho.RotationTracker.AddRotation(180)); + AddAssert("rotation is set", () => dho.Result.RateAdjustedRotation == 180); + AddStep("apply new spinner", () => dho.Apply(prepareObject(new Spinner { Position = new Vector2(256, 192), ComboIndex = 1, Duration = 1000, }), null)); + + AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0); } private Spinner prepareObject(Spinner circle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 53bf1ea566..ac8d5c81bc 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -62,11 +62,11 @@ namespace osu.Game.Rulesets.Osu.Tests trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f); }); AddAssert("is disc rotation not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, 100)); - AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, 0, 100)); + AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100)); addSeekStep(0); AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, trackerRotationTolerance)); - AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, 0, 100)); + AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100)); } [Test] @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Tests finalSpinnerSymbolRotation = spinnerSymbol.Rotation; spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); }); - AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.RotationTracker.RateAdjustedRotation); + AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); addSeekStep(2500); AddAssert("disc rotation rewound", @@ -99,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Tests () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation / 2, spinnerSymbolRotationTolerance)); AddAssert("is cumulative rotation rewound", // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. - () => Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); + () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); addSeekStep(5000); AddAssert("is disc rotation almost same", @@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("is symbol rotation almost same", () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, spinnerSymbolRotationTolerance)); AddAssert("is cumulative rotation almost same", - () => Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, finalCumulativeTrackerRotation, 100)); + () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation, 100)); } [Test] @@ -145,7 +145,7 @@ namespace osu.Game.Rulesets.Osu.Tests { // multipled by 2 to nullify the score multiplier. (autoplay mod selected) var totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; - return totalScore == (int)(drawableSpinner.RotationTracker.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; + return totalScore == (int)(drawableSpinner.Result.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; }); addSeekStep(0); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs index ba1d35c35c..eb7011e8b0 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private readonly Path path; private readonly Slider slider; - private readonly int controlPointIndex; + public int ControlPointIndex { get; set; } private IBindable sliderPosition; private IBindable pathVersion; @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public PathControlPointConnectionPiece(Slider slider, int controlPointIndex) { this.slider = slider; - this.controlPointIndex = controlPointIndex; + ControlPointIndex = controlPointIndex; Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components path.ClearVertices(); - int nextIndex = controlPointIndex + 1; + int nextIndex = ControlPointIndex + 1; if (nextIndex == 0 || nextIndex >= slider.Path.ControlPoints.Count) return; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 17541866ec..ce5dc4855e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -20,12 +20,15 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { public class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside of the playfield. + internal readonly Container Pieces; internal readonly Container Connections; @@ -66,6 +69,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components switch (e.Action) { case NotifyCollectionChangedAction.Add: + // If inserting in the path (not appending), + // update indices of existing connections after insert location + if (e.NewStartingIndex < Pieces.Count) + { + foreach (var connection in Connections) + { + if (connection.ControlPointIndex >= e.NewStartingIndex) + connection.ControlPointIndex += e.NewItems.Count; + } + } + for (int i = 0; i < e.NewItems.Count; i++) { var point = (PathControlPoint)e.NewItems[i]; @@ -88,6 +102,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components Connections.RemoveAll(c => c.ControlPoint == point); } + // If removing before the end of the path, + // update indices of connections after remove location + if (e.OldStartingIndex < Pieces.Count) + { + foreach (var connection in Connections) + { + if (connection.ControlPointIndex >= e.OldStartingIndex) + connection.ControlPointIndex -= e.OldItems.Count; + } + } + break; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 7ae4f387ca..d592e129d9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -44,6 +44,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } + private readonly BindableList controlPoints = new BindableList(); + private readonly IBindable pathVersion = new Bindable(); + public SliderSelectionBlueprint(DrawableSlider slider) : base(slider) { @@ -61,13 +64,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders }; } - private IBindable pathVersion; - protected override void LoadComplete() { base.LoadComplete(); - pathVersion = HitObject.Path.Version.GetBoundCopy(); + controlPoints.BindTo(HitObject.Path.ControlPoints); + + pathVersion.BindTo(HitObject.Path.Version); pathVersion.BindValueChanged(_ => updatePath()); BodyPiece.UpdateFrom(HitObject); @@ -164,8 +167,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } - private BindableList controlPoints => HitObject.Path.ControlPoints; - private int addControlPoint(Vector2 position) { position -= HitObject.Position; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 24bf79f9ae..ec8c68005f 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -207,11 +207,17 @@ namespace osu.Game.Rulesets.Osu.Edit Quad quad = getSurroundingQuad(hitObjects); - if (quad.TopLeft.X + delta.X < 0 || - quad.TopLeft.Y + delta.Y < 0 || - quad.BottomRight.X + delta.X > DrawWidth || - quad.BottomRight.Y + delta.Y > DrawHeight) - return false; + Vector2 newTopLeft = quad.TopLeft + delta; + if (newTopLeft.X < 0) + delta.X -= newTopLeft.X; + if (newTopLeft.Y < 0) + delta.Y -= newTopLeft.Y; + + Vector2 newBottomRight = quad.BottomRight + delta; + if (newBottomRight.X > DrawWidth) + delta.X -= newBottomRight.X - DrawWidth; + if (newBottomRight.Y > DrawHeight) + delta.Y -= newBottomRight.Y - DrawHeight; foreach (var h in hitObjects) h.Position += delta; diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs new file mode 100644 index 0000000000..e58aacd86e --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class OsuSpinnerJudgementResult : OsuJudgementResult + { + /// + /// The . + /// + public Spinner Spinner => (Spinner)HitObject; + + /// + /// The total rotation performed on the spinner disc, disregarding the spin direction, + /// adjusted for the track's playback rate. + /// + /// + /// + /// This value is always non-negative and is monotonically increasing with time + /// (i.e. will only increase if time is passing forward, but can decrease during rewind). + /// + /// + /// The rotation from each frame is multiplied by the clock's current playback rate. + /// The reason this is done is to ensure that spinners give the same score and require the same number of spins + /// regardless of whether speed-modifying mods are applied. + /// + /// + /// + /// Assuming no speed-modifying mods are active, + /// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise, + /// this property will return the value of 720 (as opposed to 0). + /// If Double Time is active instead (with a speed multiplier of 1.5x), + /// in the same scenario the property will return 720 * 1.5 = 1080. + /// + public float RateAdjustedRotation; + + /// + /// Time instant at which the spinner has been completed (the user has executed all required spins). + /// Will be null if all required spins haven't been completed. + /// + public double? TimeCompleted; + + public OsuSpinnerJudgementResult(HitObject hitObject, Judgement judgement) + : base(hitObject, judgement) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs index a981648444..3e2ab65bb2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; using osu.Game.Skinning; @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// A single follow point positioned between two adjacent s. /// - public class FollowPoint : Container, IAnimationTimeReference + public class FollowPoint : PoolableDrawable, IAnimationTimeReference { private const float width = 8; @@ -25,7 +26,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { Origin = Anchor.Centre; - Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.FollowPoint), _ => new CircularContainer + InternalChild = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.FollowPoint), _ => new CircularContainer { Masking = true, AutoSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 3a9e19b361..700d96eff3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -2,11 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; -using JetBrains.Annotations; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects; using osuTK; @@ -15,150 +12,106 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// Visualises the s between two s. /// - public class FollowPointConnection : CompositeDrawable + public class FollowPointConnection : PoolableDrawable { // Todo: These shouldn't be constants - private const int spacing = 32; - private const double preempt = 800; + public const int SPACING = 32; + public const double PREEMPT = 800; - public override bool RemoveWhenNotAlive => false; + public FollowPointLifetimeEntry Entry; + public DrawablePool Pool; - /// - /// The start time of . - /// - public readonly Bindable StartTime = new BindableDouble(); - - /// - /// The which s will exit from. - /// - [NotNull] - public readonly OsuHitObject Start; - - /// - /// Creates a new . - /// - /// The which s will exit from. - public FollowPointConnection([NotNull] OsuHitObject start) + protected override void PrepareForUse() { - Start = start; + base.PrepareForUse(); - RelativeSizeAxes = Axes.Both; + Entry.Invalidated += onEntryInvalidated; - StartTime.BindTo(start.StartTimeBindable); + refreshPoints(); } - protected override void LoadComplete() + protected override void FreeAfterUse() { - base.LoadComplete(); - bindEvents(Start); + base.FreeAfterUse(); + + Entry.Invalidated -= onEntryInvalidated; + + // Return points to the pool. + ClearInternal(false); + + Entry = null; } - private OsuHitObject end; + private void onEntryInvalidated() => refreshPoints(); - /// - /// The which s will enter. - /// - [CanBeNull] - public OsuHitObject End + private void refreshPoints() { - get => end; - set - { - end = value; + ClearInternal(false); - if (end != null) - bindEvents(end); + OsuHitObject start = Entry.Start; + OsuHitObject end = Entry.End; - if (IsLoaded) - scheduleRefresh(); - else - refresh(); - } - } + double startTime = start.GetEndTime(); - private void bindEvents(OsuHitObject obj) - { - obj.PositionBindable.BindValueChanged(_ => scheduleRefresh()); - obj.DefaultsApplied += _ => scheduleRefresh(); - } - - private void scheduleRefresh() - { - Scheduler.AddOnce(refresh); - } - - private void refresh() - { - double startTime = Start.GetEndTime(); - - LifetimeStart = startTime; - - if (End == null || End.NewCombo || Start is Spinner || End is Spinner) - { - // ensure we always set a lifetime for full LifetimeManagementContainer benefits - LifetimeEnd = LifetimeStart; - return; - } - - Vector2 startPosition = Start.StackedEndPosition; - Vector2 endPosition = End.StackedPosition; - double endTime = End.StartTime; + Vector2 startPosition = start.StackedEndPosition; + Vector2 endPosition = end.StackedPosition; Vector2 distanceVector = endPosition - startPosition; int distance = (int)distanceVector.Length; float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI)); - double duration = endTime - startTime; - double? firstTransformStartTime = null; double finalTransformEndTime = startTime; - int point = 0; - - ClearInternal(); - - for (int d = (int)(spacing * 1.5); d < distance - spacing; d += spacing) + for (int d = (int)(SPACING * 1.5); d < distance - SPACING; d += SPACING) { float fraction = (float)d / distance; Vector2 pointStartPosition = startPosition + (fraction - 0.1f) * distanceVector; Vector2 pointEndPosition = startPosition + fraction * distanceVector; - double fadeOutTime = startTime + fraction * duration; - double fadeInTime = fadeOutTime - preempt; + + GetFadeTimes(start, end, (float)d / distance, out var fadeInTime, out var fadeOutTime); FollowPoint fp; - AddInternal(fp = new FollowPoint()); - - Debug.Assert(End != null); + AddInternal(fp = Pool.Get()); + fp.ClearTransforms(); fp.Position = pointStartPosition; fp.Rotation = rotation; fp.Alpha = 0; - fp.Scale = new Vector2(1.5f * End.Scale); - - firstTransformStartTime ??= fadeInTime; + fp.Scale = new Vector2(1.5f * end.Scale); fp.AnimationStartTime = fadeInTime; using (fp.BeginAbsoluteSequence(fadeInTime)) { - fp.FadeIn(End.TimeFadeIn); - fp.ScaleTo(End.Scale, End.TimeFadeIn, Easing.Out); - fp.MoveTo(pointEndPosition, End.TimeFadeIn, Easing.Out); - fp.Delay(fadeOutTime - fadeInTime).FadeOut(End.TimeFadeIn); + fp.FadeIn(end.TimeFadeIn); + fp.ScaleTo(end.Scale, end.TimeFadeIn, Easing.Out); + fp.MoveTo(pointEndPosition, end.TimeFadeIn, Easing.Out); + fp.Delay(fadeOutTime - fadeInTime).FadeOut(end.TimeFadeIn); - finalTransformEndTime = fadeOutTime + End.TimeFadeIn; + finalTransformEndTime = fadeOutTime + end.TimeFadeIn; } - - point++; } - int excessPoints = InternalChildren.Count - point; - for (int i = 0; i < excessPoints; i++) - RemoveInternal(InternalChildren[^1]); - // todo: use Expire() on FollowPoints and take lifetime from them when https://github.com/ppy/osu-framework/issues/3300 is fixed. - LifetimeStart = firstTransformStartTime ?? startTime; - LifetimeEnd = finalTransformEndTime; + Entry.LifetimeEnd = finalTransformEndTime; + } + + /// + /// Computes the fade time of follow point positioned between two hitobjects. + /// + /// The first , where follow points should originate from. + /// The second , which follow points should target. + /// The fractional distance along and at which the follow point is to be located. + /// The fade-in time of the follow point/ + /// The fade-out time of the follow point. + public static void GetFadeTimes(OsuHitObject start, OsuHitObject end, float fraction, out double fadeInTime, out double fadeOutTime) + { + double startTime = start.GetEndTime(); + double duration = end.StartTime - startTime; + + fadeOutTime = startTime + fraction * duration; + fadeInTime = fadeOutTime - PREEMPT; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs new file mode 100644 index 0000000000..a167cb2f0f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Performance; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections +{ + public class FollowPointLifetimeEntry : LifetimeEntry + { + public event Action Invalidated; + public readonly OsuHitObject Start; + + public FollowPointLifetimeEntry(OsuHitObject start) + { + Start = start; + LifetimeStart = Start.StartTime; + + bindEvents(); + } + + private OsuHitObject end; + + public OsuHitObject End + { + get => end; + set + { + UnbindEvents(); + + end = value; + + bindEvents(); + + refreshLifetimes(); + } + } + + private void bindEvents() + { + UnbindEvents(); + + // Note: Positions are bound for instantaneous feedback from positional changes from the editor, before ApplyDefaults() is called on hitobjects. + Start.DefaultsApplied += onDefaultsApplied; + Start.PositionBindable.ValueChanged += onPositionChanged; + + if (End != null) + { + End.DefaultsApplied += onDefaultsApplied; + End.PositionBindable.ValueChanged += onPositionChanged; + } + } + + public void UnbindEvents() + { + if (Start != null) + { + Start.DefaultsApplied -= onDefaultsApplied; + Start.PositionBindable.ValueChanged -= onPositionChanged; + } + + if (End != null) + { + End.DefaultsApplied -= onDefaultsApplied; + End.PositionBindable.ValueChanged -= onPositionChanged; + } + } + + private void onDefaultsApplied(HitObject obj) => refreshLifetimes(); + + private void onPositionChanged(ValueChangedEvent obj) => refreshLifetimes(); + + private void refreshLifetimes() + { + if (End == null || End.NewCombo || Start is Spinner || End is Spinner) + { + LifetimeEnd = LifetimeStart; + return; + } + + Vector2 startPosition = Start.StackedEndPosition; + Vector2 endPosition = End.StackedPosition; + Vector2 distanceVector = endPosition - startPosition; + + // The lifetime start will match the fade-in time of the first follow point. + float fraction = (int)(FollowPointConnection.SPACING * 1.5) / distanceVector.Length; + FollowPointConnection.GetFadeTimes(Start, End, fraction, out var fadeInTime, out _); + + LifetimeStart = fadeInTime; + LifetimeEnd = double.MaxValue; // This will be set by the connection. + + Invalidated?.Invoke(); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index be1392d7c3..3e85e528e8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -2,53 +2,74 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Performance; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { /// /// Visualises connections between s. /// - public class FollowPointRenderer : LifetimeManagementContainer + public class FollowPointRenderer : CompositeDrawable { - /// - /// All the s contained by this . - /// - internal IReadOnlyList Connections => connections; - - private readonly List connections = new List(); - public override bool RemoveCompletedTransforms => false; - /// - /// Adds the s around an . - /// This includes s leading into , and s exiting . - /// - /// The to add s for. - public void AddFollowPoints(OsuHitObject hitObject) - => addConnection(new FollowPointConnection(hitObject).With(g => g.StartTime.BindValueChanged(_ => onStartTimeChanged(g)))); + public IReadOnlyList Entries => lifetimeEntries; - /// - /// Removes the s around an . - /// This includes s leading into , and s exiting . - /// - /// The to remove s for. - public void RemoveFollowPoints(OsuHitObject hitObject) => removeGroup(connections.Single(g => g.Start == hitObject)); + private DrawablePool connectionPool; + private DrawablePool pointPool; - /// - /// Adds a to this . - /// - /// The to add. - /// The index of in . - private void addConnection(FollowPointConnection connection) + private readonly List lifetimeEntries = new List(); + private readonly Dictionary connectionsInUse = new Dictionary(); + private readonly Dictionary startTimeMap = new Dictionary(); + private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); + + public FollowPointRenderer() { - // Groups are sorted by their start time when added such that the index can be used to post-process other surrounding connections - int index = connections.AddInPlace(connection, Comparer.Create((g1, g2) => + lifetimeManager.EntryBecameAlive += onEntryBecameAlive; + lifetimeManager.EntryBecameDead += onEntryBecameDead; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] { - int comp = g1.StartTime.Value.CompareTo(g2.StartTime.Value); + connectionPool = new DrawablePoolNoLifetime(1, 200), + pointPool = new DrawablePoolNoLifetime(50, 1000) + }; + } + + public void AddFollowPoints(OsuHitObject hitObject) + { + addEntry(hitObject); + + var startTimeBindable = hitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindable.ValueChanged += _ => onStartTimeChanged(hitObject); + startTimeMap[hitObject] = startTimeBindable; + } + + public void RemoveFollowPoints(OsuHitObject hitObject) + { + removeEntry(hitObject); + + startTimeMap[hitObject].UnbindAll(); + startTimeMap.Remove(hitObject); + } + + private void addEntry(OsuHitObject hitObject) + { + var newEntry = new FollowPointLifetimeEntry(hitObject); + + var index = lifetimeEntries.AddInPlace(newEntry, Comparer.Create((e1, e2) => + { + int comp = e1.Start.StartTime.CompareTo(e2.Start.StartTime); if (comp != 0) return comp; @@ -61,19 +82,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections return -1; })); - if (index < connections.Count - 1) + if (index < lifetimeEntries.Count - 1) { // Update the connection's end point to the next connection's start point // h1 -> -> -> h2 // connection nextGroup - FollowPointConnection nextConnection = connections[index + 1]; - connection.End = nextConnection.Start; + FollowPointLifetimeEntry nextEntry = lifetimeEntries[index + 1]; + newEntry.End = nextEntry.Start; } else { // The end point may be non-null during re-ordering - connection.End = null; + newEntry.End = null; } if (index > 0) @@ -82,23 +103,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections // h1 -> -> -> h2 // prevGroup connection - FollowPointConnection previousConnection = connections[index - 1]; - previousConnection.End = connection.Start; + FollowPointLifetimeEntry previousEntry = lifetimeEntries[index - 1]; + previousEntry.End = newEntry.Start; } - AddInternal(connection); + lifetimeManager.AddEntry(newEntry); } - /// - /// Removes a from this . - /// - /// The to remove. - /// Whether was removed. - private void removeGroup(FollowPointConnection connection) + private void removeEntry(OsuHitObject hitObject) { - RemoveInternal(connection); + int index = lifetimeEntries.FindIndex(e => e.Start == hitObject); - int index = connections.IndexOf(connection); + var entry = lifetimeEntries[index]; + entry.UnbindEvents(); + + lifetimeEntries.RemoveAt(index); + lifetimeManager.RemoveEntry(entry); if (index > 0) { @@ -106,18 +126,61 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections // h1 -> -> -> h2 -> -> -> h3 // prevGroup connection nextGroup // The current connection's end point is used since there may not be a next connection - FollowPointConnection previousConnection = connections[index - 1]; - previousConnection.End = connection.End; + FollowPointLifetimeEntry previousEntry = lifetimeEntries[index - 1]; + previousEntry.End = entry.End; } - - connections.Remove(connection); } - private void onStartTimeChanged(FollowPointConnection connection) + protected override bool CheckChildrenLife() { - // Naive but can be improved if performance becomes an issue - removeGroup(connection); - addConnection(connection); + bool anyAliveChanged = base.CheckChildrenLife(); + anyAliveChanged |= lifetimeManager.Update(Time.Current); + return anyAliveChanged; + } + + private void onEntryBecameAlive(LifetimeEntry entry) + { + var connection = connectionPool.Get(c => + { + c.Entry = (FollowPointLifetimeEntry)entry; + c.Pool = pointPool; + }); + + connectionsInUse[entry] = connection; + + AddInternal(connection); + } + + private void onEntryBecameDead(LifetimeEntry entry) + { + RemoveInternal(connectionsInUse[entry]); + connectionsInUse.Remove(entry); + } + + private void onStartTimeChanged(OsuHitObject hitObject) + { + removeEntry(hitObject); + addEntry(hitObject); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + foreach (var entry in lifetimeEntries) + entry.UnbindEvents(); + lifetimeEntries.Clear(); + } + + private class DrawablePoolNoLifetime : DrawablePool + where T : PoolableDrawable, new() + { + public override bool RemoveWhenNotAlive => false; + + public DrawablePoolNoLifetime(int initialSize, int? maximumSize = null) + : base(initialSize, maximumSize) + { + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 2e63160d36..d1ceca6d8f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -180,6 +180,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables this.Delay(800).FadeOut(); break; } + + Expire(); } public Drawable ProxiedLayer => ApproachCircle; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index bcaf73d34f..a26db06ede 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables @@ -60,6 +61,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables PositionBindable.BindTo(HitObject.PositionBindable); StackHeightBindable.BindTo(HitObject.StackHeightBindable); ScaleBindable.BindTo(HitObject.ScaleBindable); + + // Manually set to reduce the number of future alive objects to a bare minimum. + LifetimeStart = HitObject.StartTime - HitObject.TimePreempt; + + // Arbitrary lifetime end to prevent past objects in idle states remaining alive in non-frame-stable contexts. + // An extra 1000ms is added to always overestimate the true lifetime, and a more exact value is set by hit transforms and the following expiry. + LifetimeEnd = HitObject.GetEndTime() + HitObject.HitWindows.WindowFor(HitResult.Miss) + 1000; } protected override void OnFree(HitObject hitObject) @@ -85,14 +93,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public virtual void Shake(double maximumLength) => shakeContainer.Shake(maximumLength); - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - // Manually set to reduce the number of future alive objects to a bare minimum. - LifetimeStart = HitObject.StartTime - HitObject.TimePreempt; - } - /// /// Causes this to get missed, disregarding all conditions in implementations of . /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 98898ce1b4..13f5960bd4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -4,9 +4,9 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; -using osuTK; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -17,15 +17,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [Resolved] private OsuConfigManager config { get; set; } - public DrawableOsuJudgement(JudgementResult result, DrawableHitObject judgedObject) - : base(result, judgedObject) - { - } - - public DrawableOsuJudgement() - { - } - [BackgroundDependencyLoader] private void load() { @@ -39,48 +30,55 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } - public override void Apply(JudgementResult result, DrawableHitObject judgedObject) - { - base.Apply(result, judgedObject); - - if (judgedObject?.HitObject is OsuHitObject osuObject) - { - Position = osuObject.StackedPosition; - Scale = new Vector2(osuObject.Scale); - } - } - protected override void PrepareForUse() { base.PrepareForUse(); Lighting.ResetAnimation(); Lighting.SetColourFrom(JudgedObject, Result); - } - private double fadeOutDelay; - protected override double FadeOutDelay => fadeOutDelay; + if (JudgedObject?.HitObject is OsuHitObject osuObject) + { + Position = osuObject.StackedPosition; + Scale = new Vector2(osuObject.Scale); + } + } protected override void ApplyHitAnimations() { bool hitLightingEnabled = config.Get(OsuSetting.HitLighting); - if (hitLightingEnabled) - { - JudgementBody.FadeIn().Delay(FadeInDuration).FadeOut(400); + Lighting.Alpha = 0; + if (hitLightingEnabled && Lighting.Drawable != null) + { + // todo: this animation changes slightly based on new/old legacy skin versions. Lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); Lighting.FadeIn(200).Then().Delay(200).FadeOut(1000); - } - else - { - JudgementBody.Alpha = 1; + + // extend the lifetime to cover lighting fade + LifetimeEnd = Lighting.LatestTransformEndTime; } - fadeOutDelay = hitLightingEnabled ? 1400 : base.FadeOutDelay; - - JudgementText?.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); base.ApplyHitAnimations(); } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new OsuJudgementPiece(result); + + private class OsuJudgementPiece : DefaultJudgementPiece + { + public OsuJudgementPiece(HitResult result) + : base(result) + { + } + + public override void PlayAnimation() + { + base.PlayAnimation(); + + if (Result != HitResult.Miss) + JudgementText.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 04fc755da5..14c494d909 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -80,6 +80,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { foreach (var drawableHitObject in NestedHitObjects) drawableHitObject.AccentColour.Value = colour.NewValue; + updateBallTint(); }, true); Tracking.BindValueChanged(updateSlidingSample); @@ -192,13 +193,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return base.CreateNestedHitObject(hitObject); } - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - Body.FadeInFromZero(HitObject.TimeFadeIn); - } - public readonly Bindable Tracking = new Bindable(); protected override void Update() @@ -244,7 +238,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.ApplySkin(skin, allowFallback); - bool allowBallTint = skin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; + updateBallTint(); + } + + private void updateBallTint() + { + if (CurrentSkin == null) + return; + + bool allowBallTint = CurrentSkin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; Ball.AccentColour = allowBallTint ? AccentColour.Value : Color4.White; } @@ -264,6 +266,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.PlaySamples(); } + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + Body.FadeInFromZero(HitObject.TimeFadeIn); + } + protected override void UpdateStartTimeStateTransforms() { base.UpdateStartTimeStateTransforms(); @@ -288,7 +297,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables break; } - this.FadeOut(fade_out_time, Easing.OutQuint); + this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); } public Drawable ProxiedLayer => HeadCircle.ProxiedLayer; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 824b8806e5..2a14a7c975 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -10,8 +10,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Scoring; @@ -24,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public new Spinner HitObject => (Spinner)base.HitObject; + public new OsuSpinnerJudgementResult Result => (OsuSpinnerJudgementResult)base.Result; + public SpinnerRotationTracker RotationTracker { get; private set; } public SpinnerSpmCounter SpmCounter { get; private set; } @@ -153,7 +157,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateHitStateTransforms(state); - this.FadeOut(160); + this.FadeOut(160).Expire(); // skin change does a rewind of transforms, which will stop the spinning sound from playing if it's currently in playback. isSpinning?.TriggerChange(); @@ -197,15 +201,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // these become implicitly hit. return 1; - return Math.Clamp(RotationTracker.RateAdjustedRotation / 360 / HitObject.SpinsRequired, 0, 1); + return Math.Clamp(Result.RateAdjustedRotation / 360 / HitObject.SpinsRequired, 0, 1); } } + protected override JudgementResult CreateResult(Judgement judgement) => new OsuSpinnerJudgementResult(HitObject, judgement); + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (Time.Current < HitObject.StartTime) return; - RotationTracker.Complete.Value = Progress >= 1; + if (Progress >= 1) + Result.TimeCompleted ??= Time.Current; if (userTriggered || Time.Current < HitObject.EndTime) return; @@ -244,7 +251,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (!SpmCounter.IsPresent && RotationTracker.Tracking) SpmCounter.FadeIn(HitObject.TimeFadeIn); - SpmCounter.SetRotation(RotationTracker.RateAdjustedRotation); + SpmCounter.SetRotation(Result.RateAdjustedRotation); updateBonusScore(); } @@ -256,7 +263,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (ticks.Count == 0) return; - int spins = (int)(RotationTracker.RateAdjustedRotation / 360); + int spins = (int)(Result.RateAdjustedRotation / 360); if (spins < wholeSpins) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs index c455c66e8d..d0e1055dce 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs @@ -13,6 +13,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class CirclePiece : CompositeDrawable { + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + private TrianglesPiece triangles; + public CirclePiece() { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); @@ -26,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } [BackgroundDependencyLoader] - private void load(TextureStore textures, DrawableHitObject drawableHitObject) + private void load(TextureStore textures) { InternalChildren = new Drawable[] { @@ -36,13 +41,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Origin = Anchor.Centre, Texture = textures.Get(@"Gameplay/osu/disc"), }, - new TrianglesPiece(drawableHitObject.GetHashCode()) + triangles = new TrianglesPiece { RelativeSizeAxes = Axes.Both, Blending = BlendingParameters.Additive, Alpha = 0.5f, } }; + + drawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableObject); + } + + private void onHitObjectApplied(DrawableHitObject obj) + { + if (obj.HitObject == null) + return; + + triangles.Reset((int)obj.HitObject.StartTime); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= onHitObjectApplied; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs index 731852c221..14ce3b014d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -28,6 +29,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces private SpinnerTicks ticks; private int wholeRotationCount; + private readonly BindableBool complete = new BindableBool(); private SpinnerFill fill; private Container mainContainer; @@ -89,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { base.LoadComplete(); - drawableSpinner.RotationTracker.Complete.BindValueChanged(complete => updateComplete(complete.NewValue, 200)); + complete.BindValueChanged(complete => updateComplete(complete.NewValue, 200)); drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); @@ -99,7 +101,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { base.Update(); - if (drawableSpinner.RotationTracker.Complete.Value) + complete.Value = Time.Current >= drawableSpinner.Result.TimeCompleted; + + if (complete.Value) { if (checkNewRotationCount) { @@ -194,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { get { - int rotations = (int)(drawableSpinner.RotationTracker.RateAdjustedRotation / 360); + int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); if (wholeRotationCount == rotations) return false; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs index 6381ddca69..09299a3622 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs @@ -1,14 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class ExplodePiece : Container { + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + private TrianglesPiece triangles; + public ExplodePiece() { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); @@ -18,13 +25,36 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Blending = BlendingParameters.Additive; Alpha = 0; + } - Child = new TrianglesPiece + [BackgroundDependencyLoader] + private void load() + { + Child = triangles = new TrianglesPiece { Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, Alpha = 0.2f, }; + + drawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableObject); + } + + private void onHitObjectApplied(DrawableHitObject obj) + { + if (obj.HitObject == null) + return; + + triangles.Reset((int)obj.HitObject.StartTime); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= onHitObjectApplied; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs index 910899c307..f82003edb8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Screens.Play; using osuTK; @@ -22,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces public SpinnerRotationTracker(DrawableSpinner drawableSpinner) { this.drawableSpinner = drawableSpinner; + drawableSpinner.HitObjectApplied += resetState; RelativeSizeAxes = Axes.Both; } @@ -30,32 +32,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces public bool Tracking { get; set; } - public readonly BindableBool Complete = new BindableBool(); - - /// - /// The total rotation performed on the spinner disc, disregarding the spin direction, - /// adjusted for the track's playback rate. - /// - /// - /// - /// This value is always non-negative and is monotonically increasing with time - /// (i.e. will only increase if time is passing forward, but can decrease during rewind). - /// - /// - /// The rotation from each frame is multiplied by the clock's current playback rate. - /// The reason this is done is to ensure that spinners give the same score and require the same number of spins - /// regardless of whether speed-modifying mods are applied. - /// - /// - /// - /// Assuming no speed-modifying mods are active, - /// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise, - /// this property will return the value of 720 (as opposed to 0 for ). - /// If Double Time is active instead (with a speed multiplier of 1.5x), - /// in the same scenario the property will return 720 * 1.5 = 1080. - /// - public float RateAdjustedRotation { get; private set; } - /// /// Whether the spinning is spinning at a reasonable speed to be considered visually spinning. /// @@ -131,7 +107,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces currentRotation += angle; // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback // (see: ModTimeRamp) - RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.TrueGameplayRate ?? Clock.Rate)); + drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.TrueGameplayRate ?? Clock.Rate)); + } + + private void resetState(DrawableHitObject obj) + { + Tracking = false; + IsSpinning.Value = false; + mousePosition = default; + lastAngle = currentRotation = Rotation = 0; + rotationTransferred = false; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner != null) + drawableSpinner.HitObjectApplied -= resetState; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs index 6cdb0d3df3..53dc7ecea3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs @@ -7,7 +7,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class TrianglesPiece : Triangles { - protected override bool ExpireOffScreenTriangles => false; protected override bool CreateNewTriangles => false; protected override float SpawnRatio => 0.5f; diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 678fb8aba6..d8180b0e58 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -107,6 +107,35 @@ namespace osu.Game.Rulesets.Osu yield return new OsuModTouchDevice(); } + public override LegacyMods ConvertToLegacyMods(Mod[] mods) + { + var value = base.ConvertToLegacyMods(mods); + + foreach (var mod in mods) + { + switch (mod) + { + case OsuModAutopilot _: + value |= LegacyMods.Autopilot; + break; + + case OsuModSpunOut _: + value |= LegacyMods.SpunOut; + break; + + case OsuModTarget _: + value |= LegacyMods.Target; + break; + + case OsuModTouchDevice _: + value |= LegacyMods.TouchDevice; + break; + } + } + + return value; + } + public override IEnumerable GetModsFor(ModType type) { switch (type) diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs index eb9fa85fde..5aa136cf7e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs @@ -60,7 +60,6 @@ namespace osu.Game.Rulesets.Osu.Skinning { base.LoadComplete(); - completed.BindTo(DrawableSpinner.RotationTracker.Complete); completed.BindValueChanged(onCompletedChanged, true); DrawableSpinner.ApplyCustomUpdateState += UpdateStateTransforms; @@ -93,6 +92,12 @@ namespace osu.Game.Rulesets.Osu.Skinning } } + protected override void Update() + { + base.Update(); + completed.Value = Time.Current >= DrawableSpinner.Result.TimeCompleted; + } + protected virtual void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) { switch (drawableHitObject) diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index c816502d61..3bd150c4d3 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -20,7 +19,6 @@ using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.UI @@ -40,44 +38,21 @@ namespace osu.Game.Rulesets.Osu.UI protected override GameplayCursorContainer CreateCursor() => new OsuCursorContainer(); - private readonly Bindable playfieldBorderStyle = new BindableBool(); - private readonly IDictionary> poolDictionary = new Dictionary>(); + private readonly Container judgementAboveHitObjectLayer; + public OsuPlayfield() { InternalChildren = new Drawable[] { - playfieldBorder = new PlayfieldBorder - { - RelativeSizeAxes = Axes.Both, - Depth = 3 - }, - spinnerProxies = new ProxyContainer - { - RelativeSizeAxes = Axes.Both - }, - followPoints = new FollowPointRenderer - { - RelativeSizeAxes = Axes.Both, - Depth = 2, - }, - judgementLayer = new JudgementContainer - { - RelativeSizeAxes = Axes.Both, - Depth = 1, - }, - // Todo: This should not exist, but currently helps to reduce LOH allocations due to unbinding skin source events on judgement disposal - // Todo: Remove when hitobjects are properly pooled - new SkinProvidingContainer(null) - { - Child = HitObjectContainer, - }, - approachCircles = new ProxyContainer - { - RelativeSizeAxes = Axes.Both, - Depth = -1, - }, + playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + spinnerProxies = new ProxyContainer { RelativeSizeAxes = Axes.Both }, + followPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }, + judgementLayer = new JudgementContainer { RelativeSizeAxes = Axes.Both }, + HitObjectContainer, + judgementAboveHitObjectLayer = new Container { RelativeSizeAxes = Axes.Both }, + approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both }, }; hitPolicy = new OrderedHitPolicy(HitObjectContainer); @@ -86,13 +61,18 @@ namespace osu.Game.Rulesets.Osu.UI var hitWindows = new OsuHitWindows(); foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) - poolDictionary.Add(result, new DrawableJudgementPool(result)); + poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgmentLoaded)); AddRangeInternal(poolDictionary.Values); NewResult += onNewResult; } + private void onJudgmentLoaded(DrawableOsuJudgement judgement) + { + judgementAboveHitObjectLayer.Add(judgement.GetProxyAboveHitObjectsContent()); + } + [BackgroundDependencyLoader(true)] private void load(OsuRulesetConfigManager config) { @@ -178,11 +158,13 @@ namespace osu.Game.Rulesets.Osu.UI private class DrawableJudgementPool : DrawablePool { private readonly HitResult result; + private readonly Action onLoaded; - public DrawableJudgementPool(HitResult result) + public DrawableJudgementPool(HitResult result, Action onLoaded) : base(10) { this.result = result; + this.onLoaded = onLoaded; } protected override DrawableOsuJudgement CreateNewDrawable() @@ -192,6 +174,8 @@ namespace osu.Game.Rulesets.Osu.UI // just a placeholder to initialise the correct drawable hierarchy for this pool. judgement.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null); + onLoaded?.Invoke(judgement); + return judgement; } } diff --git a/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json index 09340f6f9f..63f25c2402 100644 --- a/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json @@ -9,7 +9,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Taiko.Tests.csproj", "-p:GenerateFullPaths=true", "-m", @@ -24,7 +23,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Rulesets.Taiko.Tests.csproj", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -33,15 +31,6 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, - { - "label": "Restore", - "type": "shell", - "command": "dotnet", - "args": [ - "restore" - ], - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs index a59544386b..a039e84106 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs @@ -12,17 +12,33 @@ namespace osu.Game.Rulesets.Taiko.Tests [TestFixture] public class TaikoLegacyModConversionTest : LegacyModConversionTest { - [TestCase(LegacyMods.Easy, new[] { typeof(TaikoModEasy) })] - [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(TaikoModHardRock), typeof(TaikoModDoubleTime) })] - [TestCase(LegacyMods.DoubleTime, new[] { typeof(TaikoModDoubleTime) })] - [TestCase(LegacyMods.Nightcore, new[] { typeof(TaikoModNightcore) })] + private static readonly object[][] taiko_mod_mapping = + { + new object[] { LegacyMods.NoFail, new[] { typeof(TaikoModNoFail) } }, + new object[] { LegacyMods.Easy, new[] { typeof(TaikoModEasy) } }, + new object[] { LegacyMods.Hidden, new[] { typeof(TaikoModHidden) } }, + new object[] { LegacyMods.HardRock, new[] { typeof(TaikoModHardRock) } }, + new object[] { LegacyMods.SuddenDeath, new[] { typeof(TaikoModSuddenDeath) } }, + new object[] { LegacyMods.DoubleTime, new[] { typeof(TaikoModDoubleTime) } }, + new object[] { LegacyMods.Relax, new[] { typeof(TaikoModRelax) } }, + new object[] { LegacyMods.HalfTime, new[] { typeof(TaikoModHalfTime) } }, + new object[] { LegacyMods.Nightcore, new[] { typeof(TaikoModNightcore) } }, + new object[] { LegacyMods.Flashlight, new[] { typeof(TaikoModFlashlight) } }, + new object[] { LegacyMods.Autoplay, new[] { typeof(TaikoModAutoplay) } }, + new object[] { LegacyMods.Perfect, new[] { typeof(TaikoModPerfect) } }, + new object[] { LegacyMods.Random, new[] { typeof(TaikoModRandom) } }, + new object[] { LegacyMods.Cinema, new[] { typeof(TaikoModCinema) } }, + new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(TaikoModHardRock), typeof(TaikoModDoubleTime) } } + }; + + [TestCaseSource(nameof(taiko_mod_mapping))] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(TaikoModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(TaikoModNightcore) })] - [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(TaikoModFlashlight), typeof(TaikoModNightcore) })] - [TestCase(LegacyMods.Perfect, new[] { typeof(TaikoModPerfect) })] - [TestCase(LegacyMods.SuddenDeath, new[] { typeof(TaikoModSuddenDeath) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(TaikoModPerfect) })] - [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(TaikoModDoubleTime), typeof(TaikoModPerfect) })] - public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + + [TestCaseSource(nameof(taiko_mod_mapping))] + public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new TaikoRuleset(); } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 73e9c16d07..2a49dd655c 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -92,6 +92,19 @@ namespace osu.Game.Rulesets.Taiko if (mods.HasFlag(LegacyMods.Relax)) yield return new TaikoModRelax(); + + if (mods.HasFlag(LegacyMods.Random)) + yield return new TaikoModRandom(); + } + + public override LegacyMods ConvertToLegacyMods(Mod[] mods) + { + var value = base.ConvertToLegacyMods(mods); + + if (mods.OfType().Any()) + value |= LegacyMods.Random; + + return value; } public override IEnumerable GetModsFor(ModType type) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index cbfc5a8628..b5e35f88b5 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Objects.Drawables; -using osu.Framework.Allocation; -using osu.Game.Graphics; -using osu.Game.Rulesets.Judgements; using osu.Framework.Graphics; -using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.UI { @@ -25,21 +22,6 @@ namespace osu.Game.Rulesets.Taiko.UI { } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - switch (Result.Type) - { - case HitResult.Ok: - JudgementBody.Colour = colours.GreenLight; - break; - - case HitResult.Great: - JudgementBody.Colour = colours.BlueLight; - break; - } - } - protected override void ApplyHitAnimations() { this.MoveToY(-100, 500); diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index d76905dab8..a3db20ce83 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -167,6 +167,21 @@ namespace osu.Game.Tests.Visual.Components AddAssert("game not muted", () => audio.Tracks.AggregateVolume.Value != 0); } + [Test] + public void TestOwnerNotRegistered() + { + PreviewTrack track = null; + + AddStep("get track", () => Add(new TestTrackOwner(track = getTrack(), registerAsOwner: false))); + AddUntilStep("wait for loaded", () => track.IsLoaded); + + AddStep("start track", () => track.Start()); + AddUntilStep("track is running", () => track.IsRunning); + + AddStep("cancel from anyone", () => trackManager.StopAnyPlaying(this)); + AddAssert("track stopped", () => !track.IsRunning); + } + private TestPreviewTrack getTrack() => (TestPreviewTrack)trackManager.Get(null); private TestPreviewTrack getOwnedTrack() @@ -181,10 +196,12 @@ namespace osu.Game.Tests.Visual.Components private class TestTrackOwner : CompositeDrawable, IPreviewTrackOwner { private readonly PreviewTrack track; + private readonly bool registerAsOwner; - public TestTrackOwner(PreviewTrack track) + public TestTrackOwner(PreviewTrack track, bool registerAsOwner = true) { this.track = track; + this.registerAsOwner = registerAsOwner; } [BackgroundDependencyLoader] @@ -196,7 +213,8 @@ namespace osu.Game.Tests.Visual.Components protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(this); + if (registerAsOwner) + dependencies.CacheAs(this); return dependencies; } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorQuickDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorQuickDelete.cs new file mode 100644 index 0000000000..9efd299fba --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorQuickDelete.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorQuickDelete : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private BlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + [Test] + public void TestQuickDeleteRemovesObject() + { + var addedObject = new HitCircle { StartTime = 1000 }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("move mouse to object", () => + { + var pos = blueprintContainer.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre; + InputManager.MoveMouseTo(pos); + }); + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestQuickDeleteRemovesSliderControlPoint() + { + Slider slider = new Slider { StartTime = 1000 }; + + PathControlPoint[] points = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(50, 0)), + new PathControlPoint(new Vector2(100, 0)) + }; + + AddStep("add slider", () => + { + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + }); + + AddStep("select added slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + + AddStep("move mouse to controlpoint", () => + { + var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + InputManager.MoveMouseTo(pos); + }); + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddAssert("slider has 2 points", () => slider.Path.ControlPoints.Count == 2); + + // second click should nuke the object completely. + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs new file mode 100644 index 0000000000..82095cb809 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneParticleExplosion : OsuTestScene + { + private ParticleExplosion explosion; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddStep("create initial", () => + { + Child = explosion = new ParticleExplosion(textures.Get("Cursor/cursortrail"), 150, 1200) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(400) + }; + }); + + AddWaitStep("wait for playback", 5); + + AddRepeatStep(@"restart animation", () => + { + explosion.Restart(); + }, 10); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 9b31dd045a..88fbf09ef4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -23,17 +23,15 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerLoader : OsuManualInputManagerTestScene + public class TestScenePlayerLoader : ScreenTestScene { private TestPlayerLoader loader; - private TestPlayerLoaderContainer container; private TestPlayer player; private bool epilepsyWarning; @@ -44,21 +42,46 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private SessionStatics sessionStatics { get; set; } + [Cached] + private readonly NotificationOverlay notificationOverlay; + + [Cached] + private readonly VolumeOverlay volumeOverlay; + + private readonly ChangelogOverlay changelogOverlay; + + public TestScenePlayerLoader() + { + AddRange(new Drawable[] + { + notificationOverlay = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + volumeOverlay = new VolumeOverlay + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }, + changelogOverlay = new ChangelogOverlay() + }); + } + + [SetUp] + public void Setup() => Schedule(() => + { + player = null; + audioManager.Volume.SetDefault(); + }); + /// /// Sets the input manager child to a new test player loader container instance. /// /// If the test player should behave like the production one. /// An action to run before player load but after bindable leases are returned. - public void ResetPlayer(bool interactive, Action beforeLoadAction = null) + private void resetPlayer(bool interactive, Action beforeLoadAction = null) { - player = null; - - audioManager.Volume.SetDefault(); - - InputManager.Clear(); - - container = new TestPlayerLoaderContainer(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive))); - beforeLoadAction?.Invoke(); Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); @@ -67,13 +90,13 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToTrack(Beatmap.Value.Track); - InputManager.Child = container; + LoadScreen(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive))); } [Test] public void TestEarlyExitBeforePlayerConstruction() { - AddStep("load dummy beatmap", () => ResetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() })); + AddStep("load dummy beatmap", () => resetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() })); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); AddStep("exit loader", () => loader.Exit()); AddUntilStep("wait for not current", () => !loader.IsCurrentScreen()); @@ -90,7 +113,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestEarlyExitAfterPlayerConstruction() { - AddStep("load dummy beatmap", () => ResetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() })); + AddStep("load dummy beatmap", () => resetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() })); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); AddAssert("mod rate applied", () => Beatmap.Value.Track.Rate != 1); AddUntilStep("wait for non-null player", () => player != null); @@ -104,7 +127,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestBlockLoadViaMouseMovement() { - AddStep("load dummy beatmap", () => ResetPlayer(false)); + AddStep("load dummy beatmap", () => resetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); AddUntilStep("wait for load ready", () => @@ -129,20 +152,18 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestBlockLoadViaFocus() { - OsuFocusedOverlayContainer overlay = null; - - AddStep("load dummy beatmap", () => ResetPlayer(false)); + AddStep("load dummy beatmap", () => resetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddStep("show focused overlay", () => { container.Add(overlay = new ChangelogOverlay { State = { Value = Visibility.Visible } }); }); - AddUntilStep("overlay visible", () => overlay.IsPresent); + AddStep("show focused overlay", () => changelogOverlay.Show()); + AddUntilStep("overlay visible", () => changelogOverlay.IsPresent); - AddUntilStep("wait for load ready", () => player.LoadState == LoadState.Ready); + AddUntilStep("wait for load ready", () => player?.LoadState == LoadState.Ready); AddRepeatStep("twiddle thumbs", () => { }, 20); AddAssert("loader still active", () => loader.IsCurrentScreen()); - AddStep("hide overlay", () => overlay.Hide()); + AddStep("hide overlay", () => changelogOverlay.Hide()); AddUntilStep("loads after idle", () => !loader.IsCurrentScreen()); } @@ -151,15 +172,9 @@ namespace osu.Game.Tests.Visual.Gameplay { SlowLoadPlayer slowPlayer = null; - AddStep("load dummy beatmap", () => ResetPlayer(false)); - AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddStep("mouse in centre", () => InputManager.MoveMouseTo(loader.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for player to be current", () => player.IsCurrentScreen()); AddStep("load slow dummy beatmap", () => { - InputManager.Child = container = new TestPlayerLoaderContainer( - loader = new TestPlayerLoader(() => slowPlayer = new SlowLoadPlayer(false, false))); - + LoadScreen(loader = new TestPlayerLoader(() => slowPlayer = new SlowLoadPlayer(false, false))); Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000); }); @@ -173,7 +188,7 @@ namespace osu.Game.Tests.Visual.Gameplay TestMod playerMod1 = null; TestMod playerMod2 = null; - AddStep("load player", () => { ResetPlayer(true, () => SelectedMods.Value = new[] { gameMod = new TestMod() }); }); + AddStep("load player", () => { resetPlayer(true, () => SelectedMods.Value = new[] { gameMod = new TestMod() }); }); AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); AddStep("mouse in centre", () => InputManager.MoveMouseTo(loader.ScreenSpaceDrawQuad.Centre)); @@ -201,7 +216,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var testMod = new TestMod(); - AddStep("load player", () => ResetPlayer(true)); + AddStep("load player", () => resetPlayer(true)); AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); AddStep("set test mod in loader", () => loader.Mods.Value = new[] { testMod }); @@ -223,7 +238,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMutedNotificationMuteButton() { - addVolumeSteps("mute button", () => container.VolumeOverlay.IsMuted.Value = true, () => !container.VolumeOverlay.IsMuted.Value); + addVolumeSteps("mute button", () => volumeOverlay.IsMuted.Value = true, () => !volumeOverlay.IsMuted.Value); } /// @@ -236,13 +251,13 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).Value = false); - AddStep("load player", () => ResetPlayer(false, beforeLoad)); + AddStep("load player", () => resetPlayer(false, beforeLoad)); AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); - AddAssert("check for notification", () => container.NotificationOverlay.UnreadCount.Value == 1); + AddAssert("check for notification", () => notificationOverlay.UnreadCount.Value == 1); AddStep("click notification", () => { - var scrollContainer = (OsuScrollContainer)container.NotificationOverlay.Children.Last(); + var scrollContainer = (OsuScrollContainer)notificationOverlay.Children.Last(); var flowContainer = scrollContainer.Children.OfType>().First(); var notification = flowContainer.First(); @@ -260,7 +275,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestEpilepsyWarning(bool warning) { AddStep("change epilepsy warning", () => epilepsyWarning = warning); - AddStep("load dummy beatmap", () => ResetPlayer(false)); + AddStep("load dummy beatmap", () => resetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); @@ -277,7 +292,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestEpilepsyWarningEarlyExit() { AddStep("set epilepsy warning", () => epilepsyWarning = true); - AddStep("load dummy beatmap", () => ResetPlayer(false)); + AddStep("load dummy beatmap", () => resetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); @@ -287,42 +302,6 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); } - private class TestPlayerLoaderContainer : Container - { - [Cached] - public readonly NotificationOverlay NotificationOverlay; - - [Cached] - public readonly VolumeOverlay VolumeOverlay; - - public TestPlayerLoaderContainer(IScreen screen) - { - RelativeSizeAxes = Axes.Both; - - OsuScreenStack stack; - - InternalChildren = new Drawable[] - { - stack = new OsuScreenStack - { - RelativeSizeAxes = Axes.Both, - }, - NotificationOverlay = new NotificationOverlay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - VolumeOverlay = new VolumeOverlay - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - } - }; - - stack.Push(screen); - } - } - private class TestPlayerLoader : PlayerLoader { public new VisualSettings VisualSettings => base.VisualSettings; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index d009d805f0..3e777119c4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -57,6 +57,43 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("DHO reused", () => this.ChildrenOfType().Single() == firstObject); } + [Test] + public void TestCustomTransformsClearedBetweenReuses() + { + ManualClock clock = null; + + createTest(new Beatmap + { + HitObjects = + { + new HitObject(), + new HitObject { StartTime = 2000 } + } + }, 1, () => new FramedClock(clock = new ManualClock())); + + DrawableTestHitObject firstObject = null; + Vector2 position = default; + + AddUntilStep("first object shown", () => this.ChildrenOfType().SingleOrDefault()?.HitObject == drawableRuleset.Beatmap.HitObjects[0]); + AddStep("get DHO", () => firstObject = this.ChildrenOfType().Single()); + AddStep("store position", () => position = firstObject.Position); + AddStep("add custom transform", () => firstObject.ApplyCustomUpdateState += onStateUpdate); + + AddStep("fast forward past first object", () => clock.CurrentTime = 1500); + AddStep("unapply custom transform", () => firstObject.ApplyCustomUpdateState -= onStateUpdate); + + AddStep("fast forward to second object", () => clock.CurrentTime = drawableRuleset.Beatmap.HitObjects[1].StartTime); + AddUntilStep("second object shown", () => this.ChildrenOfType().SingleOrDefault()?.HitObject == drawableRuleset.Beatmap.HitObjects[1]); + AddAssert("DHO reused", () => this.ChildrenOfType().Single() == firstObject); + AddAssert("object in new position", () => firstObject.Position != position); + + void onStateUpdate(DrawableHitObject hitObject, ArmedState state) + { + using (hitObject.BeginAbsoluteSequence(hitObject.StateUpdateTime)) + hitObject.MoveToOffset(new Vector2(-100, 0)); + } + } + [Test] public void TestNotReusedWithHitObjectsSpacedClose() { @@ -210,7 +247,6 @@ namespace osu.Game.Tests.Visual.Gameplay Anchor = Anchor.Centre; Origin = Anchor.Centre; - Position = new Vector2(RNG.Next(-200, 200), RNG.Next(-200, 200)); Size = new Vector2(50, 50); Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1f); @@ -225,6 +261,12 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + protected override void OnApply(HitObject hitObject) + { + base.OnApply(hitObject); + Position = new Vector2(RNG.Next(-200, 200), RNG.Next(-200, 200)); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (timeOffset > HitObject.Duration) diff --git a/osu.Game.Tournament.Tests/.vscode/tasks.json b/osu.Game.Tournament.Tests/.vscode/tasks.json index c69ac0391a..04ec7275ac 100644 --- a/osu.Game.Tournament.Tests/.vscode/tasks.json +++ b/osu.Game.Tournament.Tests/.vscode/tasks.json @@ -9,7 +9,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Tournament.Tests.csproj", "-p:GenerateFullPaths=true", "-m", @@ -24,7 +23,6 @@ "command": "dotnet", "args": [ "build", - "--no-restore", "osu.Game.Tournament.Tests.csproj", "-p:Configuration=Release", "-p:GenerateFullPaths=true", @@ -33,15 +31,6 @@ ], "group": "build", "problemMatcher": "$msCompile" - }, - { - "label": "Restore", - "type": "shell", - "command": "dotnet", - "args": [ - "restore" - ], - "problemMatcher": [] } ] } \ No newline at end of file diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index 655beb4bdd..f2065e7e88 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -144,9 +144,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components if (selected) { selectionBox.Show(); - if (editor) + if (editor && editorInfo != null) editorInfo.Selected.Value = Match; - else + else if (ladderInfo != null) ladderInfo.CurrentMatch.Value = Match; } else diff --git a/osu.Game.Tournament/Screens/SetupScreen.cs b/osu.Game.Tournament/Screens/SetupScreen.cs index 98bc292901..e78d3a9e83 100644 --- a/osu.Game.Tournament/Screens/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/SetupScreen.cs @@ -83,8 +83,8 @@ namespace osu.Game.Tournament.Screens }, new ActionableInfo { - Label = "Current User", - ButtonText = "Change Login", + Label = "Current user", + ButtonText = "Change sign-in", Action = () => { api.Logout(); @@ -102,12 +102,12 @@ namespace osu.Game.Tournament.Screens }, Value = api?.LocalUser.Value.Username, Failing = api?.IsLoggedIn != true, - Description = "In order to access the API and display metadata, a login is required." + Description = "In order to access the API and display metadata, signing in is required." }, new LabelledDropdown { Label = "Ruleset", - Description = "Decides what stats are displayed and which ranks are retrieved for players", + Description = "Decides what stats are displayed and which ranks are retrieved for players.", Items = rulesets.AvailableRulesets, Current = LadderInfo.Ruleset, }, diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index 2c539cdd43..870ea466cc 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -127,10 +127,10 @@ namespace osu.Game.Tournament new ScreenButton(typeof(ScheduleScreen)) { Text = "Schedule", RequestSelection = SetScreen }, new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen }, new Separator(), - new ScreenButton(typeof(TeamIntroScreen)) { Text = "TeamIntro", RequestSelection = SetScreen }, + new ScreenButton(typeof(TeamIntroScreen)) { Text = "Team Intro", RequestSelection = SetScreen }, new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen }, new Separator(), - new ScreenButton(typeof(MapPoolScreen)) { Text = "MapPool", RequestSelection = SetScreen }, + new ScreenButton(typeof(MapPoolScreen)) { Text = "Map Pool", RequestSelection = SetScreen }, new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen }, new Separator(), new ScreenButton(typeof(TeamWinScreen)) { Text = "Win", RequestSelection = SetScreen }, diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 862be41c1a..8d02af6574 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -11,6 +11,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.IO.Stores; +using osu.Framework.Logging; using osu.Game.Beatmaps; namespace osu.Game.Audio @@ -76,7 +77,7 @@ namespace osu.Game.Audio /// The which may be the owner of the . public void StopAnyPlaying(IPreviewTrackOwner source) { - if (CurrentTrack == null || CurrentTrack.Owner != source) + if (CurrentTrack == null || (CurrentTrack.Owner != null && CurrentTrack.Owner != source)) return; CurrentTrack.Stop(); @@ -86,11 +87,12 @@ namespace osu.Game.Audio /// /// Creates the . /// - protected virtual TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TrackManagerPreviewTrack(beatmapSetInfo, trackStore); + protected virtual TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => + new TrackManagerPreviewTrack(beatmapSetInfo, trackStore); public class TrackManagerPreviewTrack : PreviewTrack { - [Resolved] + [Resolved(canBeNull: true)] public IPreviewTrackOwner Owner { get; private set; } private readonly BeatmapSetInfo beatmapSetInfo; @@ -102,6 +104,12 @@ namespace osu.Game.Audio this.trackManager = trackManager; } + protected override void LoadComplete() + { + base.LoadComplete(); + Logger.Log($"A {nameof(PreviewTrack)} was created without a containing {nameof(IPreviewTrackOwner)}. An owner should be added for correct behaviour."); + } + protected override Track GetTrack() => trackManager.Get($"https://b.ppy.sh/preview/{beatmapSetInfo?.OnlineBeatmapSetID}.mp3"); } diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index c85ad6d651..05d6930992 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -15,103 +14,93 @@ namespace osu.Game.Database { public class UserLookupCache : MemoryCachingComponent { - private readonly HashSet nextTaskIDs = new HashSet(); - [Resolved] private IAPIProvider api { get; set; } - private readonly object taskAssignmentLock = new object(); - - private Task> pendingRequest; - - /// - /// Whether has already grabbed its IDs. - /// - private bool pendingRequestConsumedIDs; - public Task GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token); protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) - { - var users = await getQueryTaskForUser(lookup); - return users.FirstOrDefault(u => u.Id == lookup); - } + => await queryUser(lookup); - /// - /// Return the task responsible for fetching the provided user. - /// This may be part of a larger batch lookup to reduce web requests. - /// - /// The user to lookup. - /// The task responsible for the lookup. - private Task> getQueryTaskForUser(int userId) + private readonly Queue<(int id, TaskCompletionSource)> pendingUserTasks = new Queue<(int, TaskCompletionSource)>(); + private Task pendingRequestTask; + private readonly object taskAssignmentLock = new object(); + + private Task queryUser(int userId) { lock (taskAssignmentLock) { - nextTaskIDs.Add(userId); + var tcs = new TaskCompletionSource(); - // if there's a pending request which hasn't been started yet (and is not yet full), we can wait on it. - if (pendingRequest != null && !pendingRequestConsumedIDs && nextTaskIDs.Count < 50) - return pendingRequest; + // Add to the queue. + pendingUserTasks.Enqueue((userId, tcs)); - return queueNextTask(nextLookup); + // Create a request task if there's not already one. + if (pendingRequestTask == null) + createNewTask(); + + return tcs.Task; } + } - List nextLookup() + private void performLookup() + { + // contains at most 50 unique user IDs from userTasks, which is used to perform the lookup. + var userTasks = new Dictionary>>(); + + // Grab at most 50 unique user IDs from the queue. + lock (taskAssignmentLock) { - int[] lookupItems; - - lock (taskAssignmentLock) + while (pendingUserTasks.Count > 0 && userTasks.Count < 50) { - pendingRequestConsumedIDs = true; - lookupItems = nextTaskIDs.ToArray(); - nextTaskIDs.Clear(); + (int id, TaskCompletionSource task) next = pendingUserTasks.Dequeue(); - if (lookupItems.Length == 0) + // Perform a secondary check for existence, in case the user was queried in a previous batch. + if (CheckExists(next.id, out var existing)) + next.task.SetResult(existing); + else { - queueNextTask(null); - return new List(); + if (userTasks.TryGetValue(next.id, out var tasks)) + tasks.Add(next.task); + else + userTasks[next.id] = new List> { next.task }; } } - - var request = new GetUsersRequest(lookupItems); - - // rather than queueing, we maintain our own single-threaded request stream. - api.Perform(request); - - return request.Result?.Users; } - } - /// - /// Queues new work at the end of the current work tasks. - /// Ensures the provided work is eventually run. - /// - /// The work to run. Can be null to signify the end of available work. - /// The task tracking this work. - private Task> queueNextTask(Func> work) - { + // Query the users. + var request = new GetUsersRequest(userTasks.Keys.ToArray()); + + // rather than queueing, we maintain our own single-threaded request stream. + api.Perform(request); + + // Create a new request task if there's still more users to query. lock (taskAssignmentLock) { - if (work == null) - { - pendingRequest = null; - pendingRequestConsumedIDs = false; - } - else if (pendingRequest == null) - { - // special case for the first request ever. - pendingRequest = Task.Run(work); - pendingRequestConsumedIDs = false; - } - else - { - // append the new request on to the last to be executed. - pendingRequest = pendingRequest.ContinueWith(_ => work()); - pendingRequestConsumedIDs = false; - } + pendingRequestTask = null; + if (pendingUserTasks.Count > 0) + createNewTask(); + } - return pendingRequest; + foreach (var user in request.Result.Users) + { + if (userTasks.TryGetValue(user.Id, out var tasks)) + { + foreach (var task in tasks) + task.SetResult(user); + + userTasks.Remove(user.Id); + } + } + + // if any tasks remain which were not satisfied, return null. + foreach (var tasks in userTasks.Values) + { + foreach (var task in tasks) + task.SetResult(null); } } + + private void createNewTask() => pendingRequestTask = Task.Run(performLookup); } } diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 5b0fa44444..0e9382279a 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -60,6 +60,7 @@ namespace osu.Game.Graphics.Backgrounds /// /// Whether we want to expire triangles as they exit our draw area completely. /// + [Obsolete("Unused.")] // Can be removed 20210518 protected virtual bool ExpireOffScreenTriangles => true; /// @@ -86,12 +87,9 @@ namespace osu.Game.Graphics.Backgrounds /// public float Velocity = 1; - private readonly Random stableRandom; - - private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle()); - private readonly SortedList parts = new SortedList(Comparer.Default); + private Random stableRandom; private IShader shader; private readonly Texture texture; @@ -172,7 +170,20 @@ namespace osu.Game.Graphics.Backgrounds } } - protected int AimCount; + /// + /// Clears and re-initialises triangles according to a given seed. + /// + /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time. + public void Reset(int? seed = null) + { + if (seed != null) + stableRandom = new Random(seed.Value); + + parts.Clear(); + addTriangles(true); + } + + protected int AimCount { get; private set; } private void addTriangles(bool randomY) { @@ -226,6 +237,8 @@ namespace osu.Game.Graphics.Backgrounds } } + private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle()); + protected override DrawNode CreateDrawNode() => new TrianglesDrawNode(this); private class TrianglesDrawNode : DrawNode diff --git a/osu.Game/Graphics/ParticleExplosion.cs b/osu.Game/Graphics/ParticleExplosion.cs new file mode 100644 index 0000000000..e0d2b50c55 --- /dev/null +++ b/osu.Game/Graphics/ParticleExplosion.cs @@ -0,0 +1,144 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Game.Graphics +{ + /// + /// An explosion of textured particles based on how osu-stable randomises the explosion pattern. + /// + public class ParticleExplosion : Sprite + { + private readonly int particleCount; + private readonly double duration; + private double startTime; + + private readonly List parts = new List(); + + public ParticleExplosion(Texture texture, int particleCount, double duration) + { + Texture = texture; + this.particleCount = particleCount; + this.duration = duration; + Blending = BlendingParameters.Additive; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Restart(); + } + + /// + /// Restart the animation from the current point in time. + /// Supports transform time offset chaining. + /// + public void Restart() + { + startTime = TransformStartTime; + this.FadeOutFromOne(duration); + + parts.Clear(); + for (int i = 0; i < particleCount; i++) + parts.Add(new ParticlePart(duration)); + } + + protected override void Update() + { + base.Update(); + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new ParticleExplosionDrawNode(this); + + private class ParticleExplosionDrawNode : SpriteDrawNode + { + private readonly List parts = new List(); + + private ParticleExplosion source => (ParticleExplosion)Source; + + private double startTime; + private double currentTime; + private Vector2 sourceSize; + + public ParticleExplosionDrawNode(Sprite source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + parts.Clear(); + parts.AddRange(source.parts); + + sourceSize = source.Size; + startTime = source.startTime; + currentTime = source.Time.Current; + } + + protected override void Blit(Action vertexAction) + { + var time = currentTime - startTime; + + foreach (var p in parts) + { + Vector2 pos = p.PositionAtTime(time); + float alpha = p.AlphaAtTime(time); + + var rect = new RectangleF( + pos.X * sourceSize.X - Texture.DisplayWidth / 2, + pos.Y * sourceSize.Y - Texture.DisplayHeight / 2, + Texture.DisplayWidth, + Texture.DisplayHeight); + + // convert to screen space. + var quad = new Quad( + Vector2Extensions.Transform(rect.TopLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(rect.TopRight, DrawInfo.Matrix), + Vector2Extensions.Transform(rect.BottomLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(rect.BottomRight, DrawInfo.Matrix) + ); + + DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, + new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + null, TextureCoords); + } + } + } + + private readonly struct ParticlePart + { + private readonly double duration; + private readonly float direction; + private readonly float distance; + + public ParticlePart(double availableDuration) + { + distance = RNG.NextSingle(0.5f); + duration = RNG.NextDouble(availableDuration / 3, availableDuration); + direction = RNG.NextSingle(0, MathF.PI * 2); + } + + public float AlphaAtTime(double time) => 1 - progressAtTime(time); + + public Vector2 PositionAtTime(double time) + { + var travelledDistance = distance * progressAtTime(time); + return new Vector2(0.5f) + travelledDistance * new Vector2(MathF.Sin(direction), MathF.Cos(direction)); + } + + private float progressAtTime(double time) => (float)Math.Clamp(time / duration, 0, 1); + } + } +} diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index 5d9cf612bb..bed95344c6 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -46,12 +46,15 @@ namespace osu.Game.Online { if (modelInfo.NewValue == null) attachDownload(null); - else if (manager.IsAvailableLocally(modelInfo.NewValue)) + else if (manager?.IsAvailableLocally(modelInfo.NewValue) == true) State.Value = DownloadState.LocallyAvailable; else - attachDownload(manager.GetExistingDownload(modelInfo.NewValue)); + attachDownload(manager?.GetExistingDownload(modelInfo.NewValue)); }, true); + if (manager == null) + return; + managerDownloadBegan = manager.DownloadBegan.GetBoundCopy(); managerDownloadBegan.BindValueChanged(downloadBegan); managerDownloadFailed = manager.DownloadFailed.GetBoundCopy(); diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 3a5c2e181f..d18f189a70 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -248,7 +248,9 @@ namespace osu.Game.Online.Leaderboards [BackgroundDependencyLoader] private void load() { - apiState.BindTo(api.State); + if (api != null) + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); } @@ -303,7 +305,7 @@ namespace osu.Game.Online.Leaderboards PlaceholderState = PlaceholderState.NetworkFailure; }); - api.Queue(getScoresRequest); + api?.Queue(getScoresRequest); }); } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 193f6fe61b..e7b5d3304d 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -194,6 +194,20 @@ namespace osu.Game dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Audio, new NamespacedResourceStore(Resources, "Skins/Legacy"))); dependencies.CacheAs(SkinManager); + // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. + SkinManager.ItemRemoved.BindValueChanged(weakRemovedInfo => + { + if (weakRemovedInfo.NewValue.TryGetTarget(out var removedInfo)) + { + Schedule(() => + { + // check the removed skin is not the current user choice. if it is, switch back to default. + if (removedInfo.ID == SkinManager.CurrentSkinInfo.Value.ID) + SkinManager.CurrentSkinInfo.Value = SkinInfo.Default; + }); + } + }); + dependencies.CacheAs(API ??= new APIAccess(LocalConfig)); dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient()); diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index 5375476c9e..b2096968fe 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -30,7 +30,7 @@ namespace osu.Game.Overlays.AccountCreation public override void OnEntering(IScreen last) { - if (string.IsNullOrEmpty(api.ProvidedUsername)) + if (string.IsNullOrEmpty(api?.ProvidedUsername)) { this.FadeOut(); this.Push(new ScreenEntry()); @@ -43,7 +43,7 @@ namespace osu.Game.Overlays.AccountCreation [BackgroundDependencyLoader(true)] private void load(OsuColour colours, OsuGame game, TextureStore textures) { - if (string.IsNullOrEmpty(api.ProvidedUsername)) + if (string.IsNullOrEmpty(api?.ProvidedUsername)) return; InternalChildren = new Drawable[] diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 4b7de8de90..780d7ea986 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Overlays.Profile.Sections.Beatmaps { - public class PaginatedBeatmapContainer : PaginatedContainer + public class PaginatedBeatmapContainer : PaginatedProfileSubsection { private const float panel_padding = 10f; private readonly BeatmapSetType type; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs index 4445cdce51..9413d241fa 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs @@ -32,7 +32,13 @@ namespace osu.Game.Overlays.Profile.Sections.Historical Child = chart = new ProfileLineChart() }; - protected override void OnUserChanged(ValueChangedEvent e) + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(onUserChanged, true); + } + + private void onUserChanged(ValueChangedEvent e) { var values = GetValues(e.NewValue); diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index 556f3139dd..e5bb1f8008 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -13,7 +13,7 @@ using osu.Game.Users; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class PaginatedMostPlayedBeatmapContainer : PaginatedContainer + public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { public PaginatedMostPlayedBeatmapContainer(Bindable user) : base(user, "Most Played Beatmaps", "No records. :(", CounterVisibilityState.AlwaysVisible) diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs index 1b8bd23eb4..008d89d881 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs @@ -11,7 +11,7 @@ using System.Collections.Generic; namespace osu.Game.Overlays.Profile.Sections.Kudosu { - public class PaginatedKudosuHistoryContainer : PaginatedContainer + public class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection { public PaginatedKudosuHistoryContainer(Bindable user) : base(user, missingText: "This user hasn't received any kudosu!") diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs similarity index 72% rename from osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs rename to osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index 7b66c3f51e..75c3ee8290 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -12,27 +12,35 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; namespace osu.Game.Overlays.Profile.Sections { - public abstract class PaginatedContainer : ProfileSubsection + public abstract class PaginatedProfileSubsection : ProfileSubsection { [Resolved] private IAPIProvider api { get; set; } + [Resolved] + protected RulesetStore Rulesets { get; private set; } + protected int VisiblePages; protected int ItemsPerPage; - - protected FillFlowContainer ItemsContainer; + protected FillFlowContainer ItemsContainer { get; private set; } private APIRequest> retrievalRequest; private CancellationTokenSource loadCancellation; private ShowMoreButton moreButton; + private OsuSpriteText missing; + private readonly string missingText; - protected PaginatedContainer(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) - : base(user, headerText, missingText, counterVisibilityState) + protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + : base(user, headerText, counterVisibilityState) { + this.missingText = missingText; } protected override Drawable CreateContent() => new FillFlowContainer @@ -56,10 +64,22 @@ namespace osu.Game.Overlays.Profile.Sections Margin = new MarginPadding { Top = 10 }, Action = showMore, }, + missing = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 15), + Text = missingText, + Alpha = 0, + } } }; - protected override void OnUserChanged(ValueChangedEvent e) + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(onUserChanged, true); + } + + private void onUserChanged(ValueChangedEvent e) { loadCancellation?.Cancel(); retrievalRequest?.Cancel(); @@ -93,15 +113,15 @@ namespace osu.Game.Overlays.Profile.Sections moreButton.Hide(); moreButton.IsLoading = false; - if (!string.IsNullOrEmpty(Missing.Text)) - Missing.Show(); + if (!string.IsNullOrEmpty(missingText)) + missing.Show(); return; } LoadComponentsAsync(items.Select(CreateDrawableItem).Where(d => d != null), drawables => { - Missing.Hide(); + missing.Hide(); moreButton.FadeTo(items.Count == ItemsPerPage ? 1 : 0); moreButton.IsLoading = false; @@ -121,8 +141,9 @@ namespace osu.Game.Overlays.Profile.Sections protected override void Dispose(bool isDisposing) { - base.Dispose(isDisposing); retrievalRequest?.Cancel(); + loadCancellation?.Cancel(); + base.Dispose(isDisposing); } } } diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs index 751b35e342..3e331f85e9 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs @@ -5,9 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets; using osu.Game.Users; using JetBrains.Annotations; @@ -17,26 +14,20 @@ namespace osu.Game.Overlays.Profile.Sections { protected readonly Bindable User = new Bindable(); - protected RulesetStore Rulesets { get; private set; } - - protected OsuSpriteText Missing { get; private set; } - private readonly string headerText; - private readonly string missingText; private readonly CounterVisibilityState counterVisibilityState; private ProfileSubsectionHeader header; - protected ProfileSubsection(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + protected ProfileSubsection(Bindable user, string headerText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) { this.headerText = headerText; - this.missingText = missingText; this.counterVisibilityState = counterVisibilityState; User.BindTo(user); } [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + private void load() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -48,31 +39,13 @@ namespace osu.Game.Overlays.Profile.Sections { Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1 }, - CreateContent(), - Missing = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 15), - Text = missingText, - Alpha = 0, - }, + CreateContent() }; - - Rulesets = rulesets; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - User.BindValueChanged(OnUserChanged, true); } [NotNull] protected abstract Drawable CreateContent(); - protected virtual void OnUserChanged(ValueChangedEvent e) - { - } - protected void SetCount(int value) => header.Current.Value = value; } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 1ce3079d52..53f6d375ca 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -14,7 +14,7 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class PaginatedScoreContainer : PaginatedContainer + public class PaginatedScoreContainer : PaginatedProfileSubsection { private readonly ScoreType type; diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index 08f39c6272..d7101a8147 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -13,7 +13,7 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class PaginatedRecentActivityContainer : PaginatedContainer + public class PaginatedRecentActivityContainer : PaginatedProfileSubsection { public PaginatedRecentActivityContainer(Bindable user) : base(user, missingText: "This user hasn't done anything notable recently!") diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs index 873272bf12..8f757f7a36 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs @@ -217,7 +217,7 @@ namespace osu.Game.Overlays.Settings.Sections.General private void performLogin() { if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) - api.Login(username.Text, password.Text); + api?.Login(username.Text, password.Text); else shakeSignIn.Shake(); } diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index 5898c116dd..e2d4fc6051 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -76,7 +76,7 @@ namespace osu.Game // a dialog may be blocking the execution for now. if (checkForDialog(current)) return; - game.CloseAllOverlays(false); + game?.CloseAllOverlays(false); // we may already be at the target screen type. if (validScreens.Contains(getCurrentScreen().GetType()) && !beatmap.Disabled) diff --git a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs new file mode 100644 index 0000000000..d94346cb72 --- /dev/null +++ b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Rulesets.Judgements +{ + public class DefaultJudgementPiece : CompositeDrawable, IAnimatableJudgement + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } + + [Resolved] + private OsuColour colours { get; set; } + + public DefaultJudgementPiece(HitResult result) + { + Result = result; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + JudgementText = new OsuSpriteText + { + Text = Result.GetDescription().ToUpperInvariant(), + Colour = colours.ForHitResult(Result), + Font = OsuFont.Numeric.With(size: 20), + Scale = new Vector2(0.85f, 1), + } + }; + } + + public virtual void PlayAnimation() + { + switch (Result) + { + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + + break; + + default: + this.ScaleTo(0.9f); + this.ScaleTo(1, 500, Easing.OutElastic); + break; + } + + this.FadeOutFromOne(800); + } + + public Drawable GetAboveHitObjectsProxiedContent() => null; + } +} diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index d24c81536e..3063656aaf 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -1,20 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Diagnostics; using JetBrains.Annotations; -using osuTK; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Judgements { @@ -25,25 +22,29 @@ namespace osu.Game.Rulesets.Judgements { private const float judgement_size = 128; - [Resolved] - private OsuColour colours { get; set; } - public JudgementResult Result { get; private set; } + public DrawableHitObject JudgedObject { get; private set; } - protected Container JudgementBody { get; private set; } - protected SpriteText JudgementText { get; private set; } + public override bool RemoveCompletedTransforms => false; - private SkinnableDrawable bodyDrawable; + protected SkinnableDrawable JudgementBody { get; private set; } + + private readonly Container aboveHitObjectsContent; + + [Resolved] + private ISkinSource skinSource { get; set; } /// /// Duration of initial fade in. /// + [Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")] protected virtual double FadeInDuration => 100; /// /// Duration to wait until fade out begins. Defaults to . /// + [Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")] protected virtual double FadeOutDelay => FadeInDuration; /// @@ -61,6 +62,12 @@ namespace osu.Game.Rulesets.Judgements { Size = new Vector2(judgement_size); Origin = Anchor.Centre; + + AddInternal(aboveHitObjectsContent = new Container + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both + }); } [BackgroundDependencyLoader] @@ -69,15 +76,61 @@ namespace osu.Game.Rulesets.Judgements prepareDrawables(); } - protected virtual void ApplyHitAnimations() - { - JudgementBody.ScaleTo(0.9f); - JudgementBody.ScaleTo(1, 500, Easing.OutElastic); + public Drawable GetProxyAboveHitObjectsContent() => aboveHitObjectsContent.CreateProxy(); - this.Delay(FadeOutDelay).FadeOut(400); + protected override void LoadComplete() + { + base.LoadComplete(); + skinSource.SourceChanged += onSkinChanged; } - public virtual void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) + private void onSkinChanged() + { + // on a skin change, the child component will update but not get correctly triggered to play its animation. + // we need to trigger a reinitialisation to make things right. + currentDrawableType = null; + + PrepareForUse(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (skinSource != null) + skinSource.SourceChanged -= onSkinChanged; + } + + /// + /// Apply top-level animations to the current judgement when successfully hit. + /// If displaying components which require lifetime extensions, manually adjusting is required. + /// + /// + /// For animating the actual "default skin" judgement itself, it is recommended to use . + /// This allows applying animations which don't affect custom skins. + /// + protected virtual void ApplyHitAnimations() + { + } + + /// + /// Apply top-level animations to the current judgement when missed. + /// If displaying components which require lifetime extensions, manually adjusting is required. + /// + /// + /// For animating the actual "default skin" judgement itself, it is recommended to use . + /// This allows applying animations which don't affect custom skins. + /// + protected virtual void ApplyMissAnimations() + { + } + + /// + /// Associate a new result / object with this judgement. Should be called when retrieving a judgement from a pool. + /// + /// The applicable judgement. + /// The drawable object. + public void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) { Result = result; JudgedObject = judgedObject; @@ -91,34 +144,46 @@ namespace osu.Game.Rulesets.Judgements prepareDrawables(); - bodyDrawable.ResetAnimation(); + runAnimation(); + } - this.FadeInFromZero(FadeInDuration, Easing.OutQuint); - JudgementBody.ScaleTo(1); - JudgementBody.RotateTo(0); - JudgementBody.MoveTo(Vector2.Zero); + private void runAnimation() + { + ClearTransforms(true); + LifetimeStart = Result.TimeAbsolute; - switch (Result.Type) + using (BeginAbsoluteSequence(Result.TimeAbsolute, true)) { - case HitResult.None: - break; + // not sure if this should remain going forward. + JudgementBody.ResetAnimation(); - case HitResult.Miss: - JudgementBody.ScaleTo(1.6f); - JudgementBody.ScaleTo(1, 100, Easing.In); + switch (Result.Type) + { + case HitResult.None: + break; - JudgementBody.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); - JudgementBody.RotateTo(40, 800, Easing.InQuint); + case HitResult.Miss: + ApplyMissAnimations(); + break; - this.Delay(600).FadeOut(200); - break; + default: + ApplyHitAnimations(); + break; + } - default: - ApplyHitAnimations(); - break; + if (JudgementBody.Drawable is IAnimatableJudgement animatable) + { + var drawableAnimation = (Drawable)animatable; + + animatable.PlayAnimation(); + + // a derived version of DrawableJudgement may be proposing a lifetime. + // if not adjusted (or the skinned portion requires greater bounds than calculated) use the skinned source's lifetime. + double lastTransformTime = drawableAnimation.LatestTransformEndTime; + if (LifetimeEnd == double.MaxValue || lastTransformTime > LifetimeEnd) + LifetimeEnd = lastTransformTime; + } } - - Expire(true); } private HitResult? currentDrawableType; @@ -127,6 +192,7 @@ namespace osu.Game.Rulesets.Judgements { var type = Result?.Type ?? HitResult.Perfect; //TODO: better default type from ruleset + // todo: this should be removed once judgements are always pooled. if (type == currentDrawableType) return; @@ -134,21 +200,24 @@ namespace osu.Game.Rulesets.Judgements if (JudgementBody != null) RemoveInternal(JudgementBody); - AddInternal(JudgementBody = new Container + aboveHitObjectsContent.Clear(); + AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponent(type), _ => + CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = bodyDrawable = new SkinnableDrawable(new GameplaySkinComponent(type), _ => JudgementText = new OsuSpriteText - { - Text = type.GetDescription().ToUpperInvariant(), - Font = OsuFont.Numeric.With(size: 20), - Colour = colours.ForHitResult(type), - Scale = new Vector2(0.85f, 1), - }, confineMode: ConfineMode.NoScaling) }); + if (JudgementBody.Drawable is IAnimatableJudgement animatable) + { + var proxiedContent = animatable.GetAboveHitObjectsProxiedContent(); + if (proxiedContent != null) + aboveHitObjectsContent.Add(proxiedContent); + } + currentDrawableType = type; } + + protected virtual Drawable CreateDefaultJudgement(HitResult result) => new DefaultJudgementPiece(result); } } diff --git a/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs b/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs new file mode 100644 index 0000000000..b38b83b534 --- /dev/null +++ b/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Graphics; + +namespace osu.Game.Rulesets.Judgements +{ + /// + /// A skinnable judgement element which supports playing an animation from the current point in time. + /// + public interface IAnimatableJudgement : IDrawable + { + /// + /// Start the animation for this judgement from the current point in time. + /// + void PlayAnimation(); + + /// + /// Get proxied content which should be displayed above all hitobjects. + /// + [CanBeNull] + Drawable GetAboveHitObjectsProxiedContent(); + } +} diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index e8b051b4d9..a44967c21c 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -18,14 +18,17 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mods { - public abstract class ModNightcore : ModDoubleTime, IApplicableToDrawableRuleset - where TObject : HitObject + public abstract class ModNightcore : ModDoubleTime { public override string Name => "Nightcore"; public override string Acronym => "NC"; public override IconUsage? Icon => OsuIcon.ModNightcore; public override string Description => "Uguuuuuuuu..."; + } + public abstract class ModNightcore : ModNightcore, IApplicableToDrawableRuleset + where TObject : HitObject + { private readonly BindableNumber tempoAdjust = new BindableDouble(1); private readonly BindableNumber freqAdjust = new BindableDouble(1); diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index b400c532c5..ca49ed9e75 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -201,6 +201,8 @@ namespace osu.Game.Rulesets.Objects.Drawables // Copy any existing result from the entry (required for rewind / judgement revert). Result = lifetimeEntry.Result; } + else + LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; // Ensure this DHO has a result. Result ??= CreateResult(HitObject.CreateJudgement()) @@ -285,8 +287,11 @@ namespace osu.Game.Rulesets.Objects.Drawables OnFree(HitObject); HitObject = null; + Result = null; lifetimeEntry = null; + clearExistingStateTransforms(); + hasHitObjectApplied = false; } @@ -403,8 +408,7 @@ namespace osu.Game.Rulesets.Objects.Drawables double transformTime = HitObject.StartTime - InitialLifetimeOffset; - base.ApplyTransformsAt(double.MinValue, true); - base.ClearTransformsAfter(double.MinValue, true); + clearExistingStateTransforms(); using (BeginAbsoluteSequence(transformTime, true)) UpdateInitialTransforms(); @@ -432,6 +436,12 @@ namespace osu.Game.Rulesets.Objects.Drawables PlaySamples(); } + private void clearExistingStateTransforms() + { + base.ApplyTransformsAt(double.MinValue, true); + base.ClearTransformsAfter(double.MinValue, true); + } + /// /// Apply (generally fade-in) transforms leading into the start time. /// The local drawable hierarchy is recursively delayed to for convenience. @@ -638,6 +648,10 @@ namespace osu.Game.Rulesets.Objects.Drawables /// This is only used as an optimisation to delay the initial update of this and may be tuned more aggressively if required. /// It is indirectly used to decide the automatic transform offset provided to . /// A more accurate should be set for further optimisation (in , for example). + /// + /// Only has an effect if this is not being pooled. + /// For pooled s, use instead. + /// /// protected virtual double InitialLifetimeOffset => 10000; diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index d8c6da86f9..ba38c7f77d 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -10,14 +10,6 @@ namespace osu.Game.Rulesets.Objects { public static class SliderEventGenerator { - [Obsolete("Use the overload with cancellation support instead.")] // can be removed 20201115 - // ReSharper disable once RedundantOverload.Global - public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, - double? legacyLastTickOffset) - { - return Generate(startTime, spanDuration, velocity, tickDistance, totalDistance, spanCount, legacyLastTickOffset, default); - } - // ReSharper disable once MethodOverloadWithOptionalParameter public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, double? legacyLastTickOffset, CancellationToken cancellationToken = default) diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 8caadffd1d..b3b3d11ab3 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -81,10 +81,18 @@ namespace osu.Game.Rulesets value |= LegacyMods.HardRock; break; + case ModPerfect _: + value |= LegacyMods.Perfect; + break; + case ModSuddenDeath _: value |= LegacyMods.SuddenDeath; break; + case ModNightcore _: + value |= LegacyMods.Nightcore; + break; + case ModDoubleTime _: value |= LegacyMods.DoubleTime; break; @@ -100,6 +108,14 @@ namespace osu.Game.Rulesets case ModFlashlight _: value |= LegacyMods.Flashlight; break; + + case ModCinema _: + value |= LegacyMods.Cinema; + break; + + case ModAutoplay _: + value |= LegacyMods.Autoplay; + break; } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 1dc029506f..5fbda305c8 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -105,7 +105,7 @@ namespace osu.Game.Rulesets.UI { Debug.Assert(!drawableMap.ContainsKey(entry)); - var drawable = pooledObjectProvider.GetPooledDrawableRepresentation(entry.HitObject); + var drawable = pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject); if (drawable == null) throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}."); diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 80e33e0ec5..82ec653f31 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -218,9 +218,6 @@ namespace osu.Game.Rulesets.UI #region Pooling support - [Resolved(CanBeNull = true)] - private IPooledHitObjectProvider parentPooledObjectProvider { get; set; } - private readonly Dictionary pools = new Dictionary(); /// @@ -320,10 +317,7 @@ namespace osu.Game.Rulesets.UI } } - if (pool == null) - return parentPooledObjectProvider?.GetPooledDrawableRepresentation(hitObject); - - return (DrawableHitObject)pool.Get(d => + return (DrawableHitObject)pool?.Get(d => { var dho = (DrawableHitObject)d; diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 3229719d5a..df9cadebfc 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -118,8 +118,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - protected virtual Container CreateSelectionBlueprintContainer() => - new Container { RelativeSizeAxes = Axes.Both }; + protected virtual Container CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; /// /// Creates a which outlines s and handles movement of selections. @@ -338,12 +337,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether a selection was performed. private bool beginClickSelection(MouseButtonEvent e) { - foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren) + // Iterate from the top of the input stack (blueprints closest to the front of the screen first). + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse()) { if (!blueprint.IsHovered) continue; - if (SelectionHandler.HandleSelectionRequested(blueprint, e)) - return clickSelectionBegan = true; + return clickSelectionBegan = SelectionHandler.HandleSelectionRequested(blueprint, e); } return false; @@ -457,6 +456,9 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprint == null) return false; + if (snapProvider == null) + return true; + Debug.Assert(movementBlueprintOriginalPosition != null); HitObject draggedObject = movementBlueprint.HitObject; diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs new file mode 100644 index 0000000000..9e95fe4fa1 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A container for ordered by their start times. + /// + public sealed class HitObjectOrderedSelectionContainer : Container + { + public override void Add(SelectionBlueprint drawable) + { + base.Add(drawable); + bindStartTime(drawable); + } + + public override bool Remove(SelectionBlueprint drawable) + { + if (!base.Remove(drawable)) + return false; + + unbindStartTime(drawable); + return true; + } + + public override void Clear(bool disposeChildren) + { + base.Clear(disposeChildren); + unbindAllStartTimes(); + } + + private readonly Dictionary startTimeMap = new Dictionary(); + + private void bindStartTime(SelectionBlueprint blueprint) + { + var bindable = blueprint.HitObject.StartTimeBindable.GetBoundCopy(); + + bindable.BindValueChanged(_ => + { + if (LoadState >= LoadState.Ready) + SortInternal(); + }); + + startTimeMap[blueprint] = bindable; + } + + private void unbindStartTime(SelectionBlueprint blueprint) + { + startTimeMap[blueprint].UnbindAll(); + startTimeMap.Remove(blueprint); + } + + private void unbindAllStartTimes() + { + foreach (var kvp in startTimeMap) + kvp.Value.UnbindAll(); + startTimeMap.Clear(); + } + + protected override int Compare(Drawable x, Drawable y) + { + var xObj = (SelectionBlueprint)x; + var yObj = (SelectionBlueprint)y; + + // Put earlier blueprints towards the end of the list, so they handle input first + int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime); + return i == 0 ? CompareReverseChildID(x, y) : i; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 21810379cc..adf22a3370 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -110,7 +110,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// protected virtual void OnOperationBegan() { - ChangeHandler.BeginChange(); + ChangeHandler?.BeginChange(); } /// @@ -118,7 +118,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// protected virtual void OnOperationEnded() { - ChangeHandler.EndChange(); + ChangeHandler?.EndChange(); } #region User Input Handling diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 0271b2def9..2f14c607c2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -96,7 +96,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (lastDragEvent != null) OnDrag(lastDragEvent); - if (Composer != null) + if (Composer != null && timeline != null) { Composer.Playfield.PastLifetimeExtension = timeline.VisibleRange / 2; Composer.Playfield.FutureLifetimeExtension = timeline.VisibleRange / 2; @@ -201,7 +201,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public TimelineSelectionBlueprintContainer() { - AddInternal(new TimelinePart(Content = new Container { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); + AddInternal(new TimelinePart(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index d9948aa23c..46d5eb40b4 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -32,7 +32,8 @@ namespace osu.Game.Screens.Edit.Compose composer = ruleset?.CreateHitObjectComposer(); // make the composer available to the timeline and other components in this screen. - dependencies.CacheAs(composer); + if (composer != null) + dependencies.CacheAs(composer); return dependencies; } diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index 09d861522a..eab909b798 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -128,7 +128,7 @@ namespace osu.Game.Screens.Edit.Timing controlPointGroups.BindCollectionChanged((sender, args) => { table.ControlGroups = controlPointGroups; - changeHandler.SaveState(); + changeHandler?.SaveState(); }, true); } diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs index be1083ce8d..3fc1359006 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs @@ -59,6 +59,9 @@ namespace osu.Game.Screens.Multi.Lounge.Components { scheduledFilterUpdate?.Cancel(); + if (filter == null) + return; + filter.Value = new FilterCriteria { SearchString = Search.Current.Value ?? string.Empty, diff --git a/osu.Game/Screens/Play/Spectator.cs b/osu.Game/Screens/Play/Spectator.cs index 6f51771c12..71ce157296 100644 --- a/osu.Game/Screens/Play/Spectator.cs +++ b/osu.Game/Screens/Play/Spectator.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -35,7 +36,8 @@ using osuTK; namespace osu.Game.Screens.Play { - public class Spectator : OsuScreen + [Cached(typeof(IPreviewTrackOwner))] + public class Spectator : OsuScreen, IPreviewTrackOwner { private readonly User targetUser; @@ -62,6 +64,9 @@ namespace osu.Game.Screens.Play [Resolved] private RulesetStore rulesets { get; set; } + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } + private Score score; private readonly object scoreLock = new object(); @@ -275,6 +280,7 @@ namespace osu.Game.Screens.Play { watchButton.Enabled.Value = false; beatmapPanelContainer.Clear(); + previewTrackManager.StopAnyPlaying(this); } private void attemptStart() @@ -326,7 +332,6 @@ namespace osu.Game.Screens.Play { if (state?.BeatmapID == null) { - beatmapPanelContainer.Clear(); onlineBeatmap = null; return; } @@ -359,6 +364,12 @@ namespace osu.Game.Screens.Play beatmaps.Download(onlineBeatmap); } + public override bool OnExiting(IScreen next) + { + previewTrackManager.StopAnyPlaying(this); + return base.OnExiting(next); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index f8bdf0140c..ce3e618889 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -16,6 +16,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.API; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Play; @@ -149,7 +150,12 @@ namespace osu.Game.Screens.Ranking }; if (Score != null) - ScorePanelList.AddScore(Score, true); + { + // only show flair / animation when arriving after watching a play that isn't autoplay. + bool shouldFlair = player != null && !Score.Mods.Any(m => m is ModAutoplay); + + ScorePanelList.AddScore(Score, shouldFlair); + } if (player != null && allowRetry) { diff --git a/osu.Game/Skinning/LegacyJudgementPieceNew.cs b/osu.Game/Skinning/LegacyJudgementPieceNew.cs new file mode 100644 index 0000000000..ca25efaa01 --- /dev/null +++ b/osu.Game/Skinning/LegacyJudgementPieceNew.cs @@ -0,0 +1,127 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Skinning +{ + public class LegacyJudgementPieceNew : CompositeDrawable, IAnimatableJudgement + { + private readonly HitResult result; + + private readonly LegacyJudgementPieceOld temporaryOldStyle; + + private readonly Drawable mainPiece; + + private readonly ParticleExplosion particles; + + public LegacyJudgementPieceNew(HitResult result, Func createMainDrawable, Texture particleTexture) + { + this.result = result; + + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + + InternalChildren = new[] + { + mainPiece = createMainDrawable().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }) + }; + + if (particleTexture != null) + { + AddInternal(particles = new ParticleExplosion(particleTexture, 150, 1600) + { + Size = new Vector2(140), + Depth = float.MaxValue, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + if (result != HitResult.Miss) + { + //new judgement shows old as a temporary effect + AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f) + { + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } + + public void PlayAnimation() + { + var animation = mainPiece as IFramedAnimation; + + animation?.GotoFrame(0); + + if (particles != null) + { + // start the particles already some way into their animation to break cluster away from centre. + using (particles.BeginDelayedSequence(-100, true)) + particles.Restart(); + } + + const double fade_in_length = 120; + const double fade_out_delay = 500; + const double fade_out_length = 600; + + this.FadeInFromZero(fade_in_length); + this.Delay(fade_out_delay).FadeOut(fade_out_length); + + // new style non-miss judgements show the original style temporarily, with additive colour. + if (temporaryOldStyle != null) + { + temporaryOldStyle.PlayAnimation(); + + temporaryOldStyle.Hide(); + temporaryOldStyle.Delay(-16) + .FadeTo(0.5f, 56, Easing.Out).Then() + .FadeOut(300); + } + + // legacy judgements don't play any transforms if they are an animation. + if (animation?.FrameCount > 1) + return; + + switch (result) + { + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + //todo: this only applies to osu! ruleset apparently. + this.MoveTo(new Vector2(0, -2)); + this.MoveToOffset(new Vector2(0, 20), fade_out_delay + fade_out_length, Easing.In); + + float rotation = RNG.NextSingle(-8.6f, 8.6f); + + this.RotateTo(0); + this.RotateTo(rotation, fade_in_length) + .Then().RotateTo(rotation * 2, fade_out_delay + fade_out_length - fade_in_length, Easing.In); + break; + + default: + mainPiece.ScaleTo(0.9f); + mainPiece.ScaleTo(1.05f, fade_out_delay + fade_out_length); + break; + } + } + + public Drawable GetAboveHitObjectsProxiedContent() => temporaryOldStyle?.CreateProxy(); // for new style judgements, only the old style temporary display is in front of objects. + } +} diff --git a/osu.Game/Skinning/LegacyJudgementPieceOld.cs b/osu.Game/Skinning/LegacyJudgementPieceOld.cs new file mode 100644 index 0000000000..5d74ab9ae3 --- /dev/null +++ b/osu.Game/Skinning/LegacyJudgementPieceOld.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Skinning +{ + public class LegacyJudgementPieceOld : CompositeDrawable, IAnimatableJudgement + { + private readonly HitResult result; + + private readonly float finalScale; + + public LegacyJudgementPieceOld(HitResult result, Func createMainDrawable, float finalScale = 1f) + { + this.result = result; + this.finalScale = finalScale; + + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + + InternalChild = createMainDrawable(); + } + + public virtual void PlayAnimation() + { + var animation = InternalChild as IFramedAnimation; + + animation?.GotoFrame(0); + + const double fade_in_length = 120; + const double fade_out_delay = 500; + const double fade_out_length = 600; + + this.FadeInFromZero(fade_in_length); + this.Delay(fade_out_delay).FadeOut(fade_out_length); + + // legacy judgements don't play any transforms if they are an animation. + if (animation?.FrameCount > 1) + return; + + switch (result) + { + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + float rotation = RNG.NextSingle(-8.6f, 8.6f); + + this.RotateTo(0); + this.RotateTo(rotation, fade_in_length) + .Then().RotateTo(rotation * 2, fade_out_delay + fade_out_length - fade_in_length, Easing.In); + break; + + default: + + this.ScaleTo(0.6f).Then() + .ScaleTo(1.1f, fade_in_length * 0.8f).Then() + // this is actually correct to match stable; there were overlapping transforms. + .ScaleTo(0.9f).Delay(fade_in_length * 0.2f) + .ScaleTo(1.1f).ScaleTo(0.9f, fade_in_length * 0.2f).Then() + .ScaleTo(0.95f).ScaleTo(finalScale, fade_in_length * 0.2f); + break; + } + } + + public Drawable GetAboveHitObjectsProxiedContent() => CreateProxy(); + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index fb020f4e39..63a22eba62 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -371,19 +371,15 @@ namespace osu.Game.Skinning } case GameplaySkinComponent resultComponent: - switch (resultComponent.Component) + Func createDrawable = () => getJudgementAnimation(resultComponent.Component); + + // kind of wasteful that we throw this away, but should do for now. + if (createDrawable() != null) { - case HitResult.Miss: - return this.GetAnimation("hit0", true, false); - - case HitResult.Meh: - return this.GetAnimation("hit50", true, false); - - case HitResult.Ok: - return this.GetAnimation("hit100", true, false); - - case HitResult.Great: - return this.GetAnimation("hit300", true, false); + if (Configuration.LegacyVersion > 1) + return new LegacyJudgementPieceNew(resultComponent.Component, createDrawable, getParticleTexture(resultComponent.Component)); + else + return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); } break; @@ -392,6 +388,43 @@ namespace osu.Game.Skinning return this.GetAnimation(component.LookupName, false, false); } + private Texture getParticleTexture(HitResult result) + { + switch (result) + { + case HitResult.Meh: + return GetTexture("particle50"); + + case HitResult.Ok: + return GetTexture("particle100"); + + case HitResult.Great: + return GetTexture("particle300"); + } + + return null; + } + + private Drawable getJudgementAnimation(HitResult result) + { + switch (result) + { + case HitResult.Miss: + return this.GetAnimation("hit0", true, false); + + case HitResult.Meh: + return this.GetAnimation("hit50", true, false); + + case HitResult.Ok: + return this.GetAnimation("hit100", true, false); + + case HitResult.Great: + return this.GetAnimation("hit300", true, false); + } + + return null; + } + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { foreach (var name in getFallbackNames(componentName)) diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index bef3e86a4d..9b69a1eecd 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -48,16 +48,6 @@ namespace osu.Game.Skinning this.audio = audio; this.legacyDefaultResources = legacyDefaultResources; - ItemRemoved.BindValueChanged(weakRemovedInfo => - { - if (weakRemovedInfo.NewValue.TryGetTarget(out var removedInfo)) - { - // check the removed skin is not the current user choice. if it is, switch back to default. - if (removedInfo.ID == CurrentSkinInfo.Value.ID) - CurrentSkinInfo.Value = SkinInfo.Default; - } - }); - CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); CurrentSkin.ValueChanged += skin => { diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 5352928ec6..1340d1474c 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -24,7 +24,15 @@ namespace osu.Game.Skinning { } - protected override Drawable CreateDefault(ISkinComponent component) => new Sprite { Texture = textures.Get(component.LookupName) }; + protected override Drawable CreateDefault(ISkinComponent component) + { + var texture = textures.Get(component.LookupName); + + if (texture == null) + return null; + + return new Sprite { Texture = texture }; + } private class SpriteComponent : ISkinComponent { diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs index e93bf916c7..76f97db59f 100644 --- a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Beatmaps /// protected abstract Ruleset CreateRuleset(); - protected void Test(LegacyMods legacyMods, Type[] expectedMods) + protected void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) { var ruleset = CreateRuleset(); var mods = ruleset.ConvertFromLegacyMods(legacyMods).ToList(); @@ -31,5 +31,15 @@ namespace osu.Game.Tests.Beatmaps Assert.IsNotNull(mods.SingleOrDefault(mod => mod.GetType() == modType)); } } + + protected void TestToLegacy(LegacyMods expectedLegacyMods, Type[] providedModTypes) + { + var ruleset = CreateRuleset(); + var modInstances = ruleset.GetAllMods() + .Where(mod => providedModTypes.Contains(mod.GetType())) + .ToArray(); + var actualLegacyMods = ruleset.ConvertToLegacyMods(modInstances); + Assert.AreEqual(expectedLegacyMods, actualLegacyMods); + } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 1850ee3488..54f3fcede6 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -26,7 +26,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index 2ac23f1503..692dac909a 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -88,7 +88,7 @@ - +