diff --git a/.idea/.idea.osu.Desktop/.idea/modules.xml b/.idea/.idea.osu.Desktop/.idea/modules.xml index 366f172c30..fe63f5faf3 100644 --- a/.idea/.idea.osu.Desktop/.idea/modules.xml +++ b/.idea/.idea.osu.Desktop/.idea/modules.xml @@ -2,7 +2,6 @@ - diff --git a/global.json b/global.json index 9aa5b6192b..233a040d18 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,6 @@ "version": "3.1.100" }, "msbuild-sdks": { - "Microsoft.Build.Traversal": "2.0.50" + "Microsoft.Build.Traversal": "2.0.52" } } \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index 493b1f5529..e5fed09c07 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 2e5fa59d20..9839d16030 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -9,7 +9,7 @@ using osu.Framework.Android; namespace osu.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullSensor, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = true)] + [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullSensor, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false)] public class OsuGameActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuGameAndroid(); diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index cd31df316a..2079f136d2 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -16,6 +16,7 @@ using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Screens.Menu; using osu.Game.Updater; +using osu.Desktop.Windows; namespace osu.Desktop { @@ -98,6 +99,9 @@ namespace osu.Desktop LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, Add); LoadComponentAsync(new DiscordRichPresence(), Add); + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + LoadComponentAsync(new GameplayWinKeyBlocker(), Add); } protected override void ScreenChanged(IScreen lastScreen, IScreen newScreen) diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs new file mode 100644 index 0000000000..86174ceb90 --- /dev/null +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Configuration; + +namespace osu.Desktop.Windows +{ + public class GameplayWinKeyBlocker : Component + { + private Bindable allowScreenSuspension; + private Bindable disableWinKey; + + private GameHost host; + + [BackgroundDependencyLoader] + private void load(GameHost host, OsuConfigManager config) + { + this.host = host; + + allowScreenSuspension = host.AllowScreenSuspension.GetBoundCopy(); + allowScreenSuspension.BindValueChanged(_ => updateBlocking()); + + disableWinKey = config.GetBindable(OsuSetting.GameplayDisableWinKey); + disableWinKey.BindValueChanged(_ => updateBlocking(), true); + } + + private void updateBlocking() + { + bool shouldDisable = disableWinKey.Value && !allowScreenSuspension.Value; + + if (shouldDisable) + host.InputThread.Scheduler.Add(WindowsKey.Disable); + else + host.InputThread.Scheduler.Add(WindowsKey.Enable); + } + } +} diff --git a/osu.Desktop/Windows/WindowsKey.cs b/osu.Desktop/Windows/WindowsKey.cs new file mode 100644 index 0000000000..f19d741107 --- /dev/null +++ b/osu.Desktop/Windows/WindowsKey.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Runtime.InteropServices; + +namespace osu.Desktop.Windows +{ + internal class WindowsKey + { + private delegate int LowLevelKeyboardProcDelegate(int nCode, int wParam, ref KdDllHookStruct lParam); + + private static bool isBlocked; + + private const int wh_keyboard_ll = 13; + private const int wm_keydown = 256; + private const int wm_syskeyup = 261; + + //Resharper disable once NotAccessedField.Local + private static LowLevelKeyboardProcDelegate keyboardHookDelegate; // keeping a reference alive for the GC + private static IntPtr keyHook; + + [StructLayout(LayoutKind.Explicit)] + private readonly struct KdDllHookStruct + { + [FieldOffset(0)] + public readonly int VkCode; + + [FieldOffset(8)] + public readonly int Flags; + } + + private static int lowLevelKeyboardProc(int nCode, int wParam, ref KdDllHookStruct lParam) + { + if (wParam >= wm_keydown && wParam <= wm_syskeyup) + { + switch (lParam.VkCode) + { + case 0x5B: // left windows key + case 0x5C: // right windows key + return 1; + } + } + + return callNextHookEx(0, nCode, wParam, ref lParam); + } + + internal static void Disable() + { + if (keyHook != IntPtr.Zero || isBlocked) + return; + + keyHook = setWindowsHookEx(wh_keyboard_ll, (keyboardHookDelegate = lowLevelKeyboardProc), Marshal.GetHINSTANCE(System.Reflection.Assembly.GetExecutingAssembly().GetModules()[0]), 0); + + isBlocked = true; + } + + internal static void Enable() + { + if (keyHook == IntPtr.Zero || !isBlocked) + return; + + keyHook = unhookWindowsHookEx(keyHook); + keyboardHookDelegate = null; + + keyHook = IntPtr.Zero; + + isBlocked = false; + } + + [DllImport(@"user32.dll", EntryPoint = @"SetWindowsHookExA")] + private static extern IntPtr setWindowsHookEx(int idHook, LowLevelKeyboardProcDelegate lpfn, IntPtr hMod, int dwThreadId); + + [DllImport(@"user32.dll", EntryPoint = @"UnhookWindowsHookEx")] + private static extern IntPtr unhookWindowsHookEx(IntPtr hHook); + + [DllImport(@"user32.dll", EntryPoint = @"CallNextHookEx")] + private static extern int callNextHookEx(int hHook, int nCode, int wParam, ref KdDllHookStruct lParam); + } +} diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 41e726e05c..ff26f4afaa 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs index f4749be370..df54df7b01 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs @@ -8,7 +8,6 @@ using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Tests.Beatmaps; @@ -83,7 +82,7 @@ namespace osu.Game.Rulesets.Catch.Tests public float Position { - get => HitObject?.X * CatchPlayfield.BASE_WIDTH ?? position; + get => HitObject?.X ?? position; set => position = value; } diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs index 3e06e78dba..c1b7214d72 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods public void TestDroplet(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Droplet { StartTime = 1000 }), shouldMiss); // We only care about testing misses, hits are tested via JuiceStream - [TestCase(true)] + [TestCase(false)] public void TestTinyDroplet(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new TinyDroplet { StartTime = 1000 }), shouldMiss); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index 7c2304694f..d6bba3d55e 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -27,15 +27,15 @@ namespace osu.Game.Rulesets.Catch.Tests for (int i = 0; i < 100; i++) { - float width = (i % 10 + 1) / 20f; + float width = (i % 10 + 1) / 20f * CatchPlayfield.WIDTH; beatmap.HitObjects.Add(new JuiceStream { - X = 0.5f - width / 2, + X = CatchPlayfield.CENTER_X - width / 2, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, - new Vector2(width * CatchPlayfield.BASE_WIDTH, 0) + new Vector2(width, 0) }), StartTime = i * 2000, NewCombo = i % 8 == 0 diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs new file mode 100644 index 0000000000..f15da29993 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneCatchModHidden : ModTestScene + { + [BackgroundDependencyLoader] + private void load() + { + LocalConfig.Set(OsuSetting.IncreaseFirstObjectVisibility, false); + } + + [Test] + public void TestJuiceStream() + { + CreateModTest(new ModTestData + { + Beatmap = new Beatmap + { + HitObjects = new List + { + new JuiceStream + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(0, -192) }), + X = CatchPlayfield.WIDTH / 2 + } + } + }, + Mod = new CatchModHidden(), + PassCondition = () => Player.Results.Count > 0 + && Player.ChildrenOfType().Single().Alpha > 0 + && Player.ChildrenOfType().Last().Alpha > 0 + }); + } + + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs index 44672b6526..1ff31697b8 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Tests { @@ -22,7 +23,14 @@ namespace osu.Game.Rulesets.Catch.Tests }; for (int i = 0; i < 512; i++) - beatmap.HitObjects.Add(new Fruit { X = 0.5f + i / 2048f * (i % 10 - 5), StartTime = i * 100, NewCombo = i % 8 == 0 }); + { + beatmap.HitObjects.Add(new Fruit + { + X = (0.5f + i / 2048f * (i % 10 - 5)) * CatchPlayfield.WIDTH, + StartTime = i * 100, + NewCombo = i % 8 == 0 + }); + } return beatmap; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 2b30edb70b..b4f123598b 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects; @@ -25,6 +26,11 @@ namespace osu.Game.Rulesets.Catch.Tests { private RulesetInfo catchRuleset; + [Resolved] + private OsuConfigManager config { get; set; } + + private Catcher catcher => this.ChildrenOfType().First().MovableCatcher; + public TestSceneCatcherArea() { AddSliderStep("CircleSize", 0, 8, 5, createCatcher); @@ -34,24 +40,43 @@ namespace osu.Game.Rulesets.Catch.Tests AddRepeatStep("catch fruit", () => catchFruit(new TestFruit(false) { - X = this.ChildrenOfType().First().MovableCatcher.X + X = catcher.X }), 20); AddRepeatStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) { - X = this.ChildrenOfType().First().MovableCatcher.X, + X = catcher.X, LastInCombo = true, }), 20); AddRepeatStep("catch kiai fruit", () => catchFruit(new TestFruit(true) { - X = this.ChildrenOfType().First().MovableCatcher.X, + X = catcher.X }), 20); AddRepeatStep("miss fruit", () => catchFruit(new Fruit { - X = this.ChildrenOfType().First().MovableCatcher.X + 100, + X = catcher.X + 100, LastInCombo = true, }, true), 20); } + [TestCase(true)] + [TestCase(false)] + public void TestHitLighting(bool enable) + { + AddStep("create catcher", () => createCatcher(5)); + + AddStep("toggle hit lighting", () => config.Set(OsuSetting.HitLighting, enable)); + AddStep("catch fruit", () => catchFruit(new TestFruit(false) + { + X = catcher.X + })); + AddStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) + { + X = catcher.X, + LastInCombo = true + })); + AddAssert("check hit explosion", () => catcher.ChildrenOfType().Any() == enable); + } + private void catchFruit(Fruit fruit, bool miss = false) { this.ChildrenOfType().ForEach(area => @@ -76,8 +101,8 @@ namespace osu.Game.Rulesets.Catch.Tests RelativeSizeAxes = Axes.Both, Child = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopLeft, + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, CreateDrawableRepresentation = ((DrawableRuleset)catchRuleset.CreateInstance().CreateDrawableRulesetWith(new CatchBeatmap())).CreateDrawableRepresentation }, }); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index a7094c00be..d35f828e28 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -158,8 +158,8 @@ namespace osu.Game.Rulesets.Catch.Tests private float getXCoords(bool hit) { - const float x_offset = 0.2f; - float xCoords = drawableRuleset.Playfield.Width / 2; + const float x_offset = 0.2f * CatchPlayfield.WIDTH; + float xCoords = CatchPlayfield.CENTER_X; if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield) catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs index a0dcb86d57..ad24adf352 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs @@ -47,13 +47,13 @@ namespace osu.Game.Rulesets.Catch.Tests }; // Should produce a hyper-dash (edge case test) - beatmap.HitObjects.Add(new Fruit { StartTime = 1816, X = 56 / 512f, NewCombo = true }); - beatmap.HitObjects.Add(new Fruit { StartTime = 2008, X = 308 / 512f, NewCombo = true }); + beatmap.HitObjects.Add(new Fruit { StartTime = 1816, X = 56, NewCombo = true }); + beatmap.HitObjects.Add(new Fruit { StartTime = 2008, X = 308, NewCombo = true }); double startTime = 3000; - const float left_x = 0.02f; - const float right_x = 0.98f; + const float left_x = 0.02f * CatchPlayfield.WIDTH; + const float right_x = 0.98f * CatchPlayfield.WIDTH; createObjects(() => new Fruit { X = left_x }); createObjects(() => new TestJuiceStream(right_x), 1); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs index ffcf61a4bf..269e783899 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Catch.Tests { new JuiceStream { - X = 0.5f, + X = CatchPlayfield.CENTER_X, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, @@ -40,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Tests }, new Banana { - X = 0.5f, + X = CatchPlayfield.CENTER_X, StartTime = 1000, NewCombo = true } diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index cbd3dc5518..7c0b73e8c3 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 0de2060e2d..145a40f5f5 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -5,7 +5,6 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using System.Collections.Generic; using System.Linq; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects; using osu.Framework.Extensions.IEnumerableExtensions; @@ -36,7 +35,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Path = curveData.Path, NodeSamples = curveData.NodeSamples, RepeatCount = curveData.RepeatCount, - X = (positionData?.X ?? 0) / CatchPlayfield.BASE_WIDTH, + X = positionData?.X ?? 0, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0 @@ -59,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Samples = obj.Samples, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - X = (positionData?.X ?? 0) / CatchPlayfield.BASE_WIDTH + X = positionData?.X ?? 0 }.Yield(); } } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs index 7c81bcdf0c..bb14988414 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps case BananaShower bananaShower: foreach (var banana in bananaShower.NestedHitObjects.OfType()) { - banana.XOffset = (float)rng.NextDouble(); + banana.XOffset = (float)(rng.NextDouble() * CatchPlayfield.WIDTH); rng.Next(); // osu!stable retrieved a random banana type rng.Next(); // osu!stable retrieved a random banana rotation rng.Next(); // osu!stable retrieved a random banana colour @@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps case JuiceStream juiceStream: // Todo: BUG!! Stable used the last control point as the final position of the path, but it should use the computed path instead. - lastPosition = juiceStream.X + juiceStream.Path.ControlPoints[^1].Position.Value.X / CatchPlayfield.BASE_WIDTH; + lastPosition = juiceStream.X + juiceStream.Path.ControlPoints[^1].Position.Value.X; // Todo: BUG!! Stable attempted to use the end time of the stream, but referenced it too early in execution and used the start time instead. lastStartTime = juiceStream.StartTime; @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps catchObject.XOffset = 0; if (catchObject is TinyDroplet) - catchObject.XOffset = Math.Clamp(rng.Next(-20, 20) / CatchPlayfield.BASE_WIDTH, -catchObject.X, 1 - catchObject.X); + catchObject.XOffset = Math.Clamp(rng.Next(-20, 20), -catchObject.X, CatchPlayfield.WIDTH - catchObject.X); else if (catchObject is Droplet) rng.Next(); // osu!stable retrieved a random droplet rotation } @@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps } // ReSharper disable once PossibleLossOfFraction - if (Math.Abs(positionDiff * CatchPlayfield.BASE_WIDTH) < timeDiff / 3) + if (Math.Abs(positionDiff) < timeDiff / 3) applyOffset(ref offsetPosition, positionDiff); hitObject.XOffset = offsetPosition - hitObject.X; @@ -149,12 +149,12 @@ namespace osu.Game.Rulesets.Catch.Beatmaps private static void applyRandomOffset(ref float position, double maxOffset, FastRandom rng) { bool right = rng.NextBool(); - float rand = Math.Min(20, (float)rng.Next(0, Math.Max(0, maxOffset))) / CatchPlayfield.BASE_WIDTH; + float rand = Math.Min(20, (float)rng.Next(0, Math.Max(0, maxOffset))); if (right) { // Clamp to the right bound - if (position + rand <= 1) + if (position + rand <= CatchPlayfield.WIDTH) position += rand; else position -= rand; @@ -211,7 +211,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps objectWithDroplets.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); - double halfCatcherWidth = CatcherArea.GetCatcherSize(beatmap.BeatmapInfo.BaseDifficulty) / 2; + double halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) / 2; int lastDirection = 0; double lastExcess = halfCatcherWidth; diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index ca75a816f1..9437023c70 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -21,11 +21,13 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using System; +using osu.Framework.Testing; using osu.Game.Rulesets.Catch.Skinning; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch { + [ExcludeFromDynamicCompile] public class CatchRuleset : Ruleset, ILegacyRuleset { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableCatchRuleset(this, beatmap, mods); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 2ee7cea645..d700f79e5b 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -78,7 +78,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty if (mods.Any(m => m is ModHidden)) { - value *= 1.05 + 0.075 * (10.0 - Math.Min(10.0, Attributes.ApproachRate)); // 7.5% for each AR below 10 // Hiddens gives almost nothing on max approach rate, and more the lower it is if (approachRate <= 10.0) value *= 1.05 + 0.075 * (10.0 - approachRate); // 7.5% for each AR below 10 diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 360af1a8c9..3e21b8fbaf 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; @@ -33,8 +32,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. var scalingFactor = normalized_hitobject_radius / halfCatcherWidth; - NormalizedPosition = BaseObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; - LastNormalizedPosition = LastObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; + NormalizedPosition = BaseObject.X * scalingFactor; + LastNormalizedPosition = LastObject.X * scalingFactor; // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure StrainTime = Math.Max(40, DeltaTime); diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 918ed77683..e679231638 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; @@ -68,7 +67,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills } // Bonus for edge dashes. - if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f / CatchPlayfield.BASE_WIDTH) + if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) { if (!catchCurrent.LastObject.HyperDash) edgeDashBonus += 5.7; @@ -78,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills playerPosition = catchCurrent.NormalizedPosition; } - distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash * CatchPlayfield.BASE_WIDTH) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catchCurrent.ClockRate, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values + distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catchCurrent.ClockRate, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } lastPlayerPosition = playerPosition; diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs index fc030877f1..a7449ba4e1 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Judgements return 0; case HitResult.Perfect: - return 0.01; + return DEFAULT_MAX_HEALTH_INCREASE * 0.75; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs b/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs index e3391c47f1..fb92399102 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModPerfect.cs @@ -1,17 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Catch.Judgements; -using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Mods { public class CatchModPerfect : ModPerfect { - protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) - => !(result.Judgement is CatchBananaJudgement) - && base.FailCondition(healthProcessor, result); } } diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 0b3d1d23e0..4ecfb7b16d 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Judgements; @@ -8,8 +10,27 @@ namespace osu.Game.Rulesets.Catch.Objects { public class Banana : Fruit { + /// + /// Index of banana in current shower. + /// + public int BananaIndex; + public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; public override Judgement CreateJudgement() => new CatchBananaJudgement(); + + private static readonly List samples = new List { new BananaHitSampleInfo() }; + + public Banana() + { + Samples = samples; + } + + private class BananaHitSampleInfo : HitSampleInfo + { + private static string[] lookupNames { get; } = { "metronomelow", "catch-banana" }; + + public override IEnumerable LookupNames => lookupNames; + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index 04a995c77e..89c51459a6 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs @@ -30,15 +30,21 @@ namespace osu.Game.Rulesets.Catch.Objects if (spacing <= 0) return; - for (double i = StartTime; i <= EndTime; i += spacing) + double time = StartTime; + int i = 0; + + while (time <= EndTime) { cancellationToken.ThrowIfCancellationRequested(); AddNested(new Banana { - Samples = Samples, - StartTime = i + StartTime = time, + BananaIndex = i, }); + + time += spacing; + i++; } } diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index f3b566f340..04932ecdbb 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -17,6 +18,9 @@ namespace osu.Game.Rulesets.Catch.Objects private float x; + /// + /// The horizontal position of the fruit between 0 and . + /// public float X { get => x + XOffset; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs index 01b76ceed9..a865984d45 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs @@ -40,6 +40,13 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables float getRandomAngle() => 180 * (RNG.NextSingle() * 2 - 1); } + public override void PlaySamples() + { + base.PlaySamples(); + if (Samples != null) + Samples.Frequency.Value = 0.77f + ((Banana)HitObject).BananaIndex * 0.006f; + } + private Color4 getBananaColour() { switch (RNG.Next(0, 3)) diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs index b12cdd4ccb..c6345a9df7 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Catch.UI; using osuTK; using osuTK.Graphics; @@ -70,12 +71,11 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables public float DisplayRadius => DrawSize.X / 2 * Scale.X * HitObject.Scale; - protected override float SamplePlaybackPosition => HitObject.X; + protected override float SamplePlaybackPosition => HitObject.X / CatchPlayfield.WIDTH; protected DrawableCatchHitObject(CatchHitObject hitObject) : base(hitObject) { - RelativePositionAxes = Axes.X; X = hitObject.X; } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 2c96ee2b19..6b8b70ed54 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -7,7 +7,6 @@ using System.Threading; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -80,7 +79,7 @@ namespace osu.Game.Rulesets.Catch.Objects { StartTime = t + lastEvent.Value.Time, X = X + Path.PositionAt( - lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X / CatchPlayfield.BASE_WIDTH, + lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X, }); } } @@ -97,7 +96,7 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = dropletSamples, StartTime = e.Time, - X = X + Path.PositionAt(e.PathProgress).X / CatchPlayfield.BASE_WIDTH, + X = X + Path.PositionAt(e.PathProgress).X, }); break; @@ -108,14 +107,14 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = Samples, StartTime = e.Time, - X = X + Path.PositionAt(e.PathProgress).X / CatchPlayfield.BASE_WIDTH, + X = X + Path.PositionAt(e.PathProgress).X, }); break; } } } - public float EndX => X + this.CurvePositionAt(1).X / CatchPlayfield.BASE_WIDTH; + public float EndX => X + this.CurvePositionAt(1).X; public double Duration { diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index 7a33cb0577..5d11c574b1 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.Replays // todo: add support for HT DT const double dash_speed = Catcher.BASE_SPEED; const double movement_speed = dash_speed / 2; - float lastPosition = 0.5f; + float lastPosition = CatchPlayfield.CENTER_X; double lastTime = 0; void moveToNext(CatchHitObject h) @@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Replays bool impossibleJump = speedRequired > movement_speed * 2; // todo: get correct catcher size, based on difficulty CS. - const float catcher_width_half = CatcherArea.CATCHER_SIZE / CatchPlayfield.BASE_WIDTH * 0.3f * 0.5f; + const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f; if (lastPosition - catcher_width_half < h.X && lastPosition + catcher_width_half > h.X) { diff --git a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs index f122588a2b..99d899db80 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs @@ -35,18 +35,15 @@ namespace osu.Game.Rulesets.Catch.Replays } } - public override List GetPendingInputs() + public override void CollectPendingInputs(List inputs) { - if (!Position.HasValue) return new List(); + if (!Position.HasValue) return; - return new List + inputs.Add(new CatchReplayState { - new CatchReplayState - { - PressedActions = CurrentFrame?.Actions ?? new List(), - CatcherX = Position.Value - }, - }; + PressedActions = CurrentFrame?.Actions ?? new List(), + CatcherX = Position.Value + }); } public class CatchReplayState : ReplayState diff --git a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs index 9dab3ed630..7efd832f62 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Replays.Legacy; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -41,7 +40,7 @@ namespace osu.Game.Rulesets.Catch.Replays public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - Position = currentFrame.Position.X / CatchPlayfield.BASE_WIDTH; + Position = currentFrame.Position.X; Dashing = currentFrame.ButtonState == ReplayButtonState.Left1; if (Dashing) @@ -63,7 +62,7 @@ namespace osu.Game.Rulesets.Catch.Replays if (Actions.Contains(CatchAction.Dash)) state |= ReplayButtonState.Left1; - return new LegacyReplayFrame(Time, Position * CatchPlayfield.BASE_WIDTH, null, state); + return new LegacyReplayFrame(Time, Position, null, state); } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 2319c5ac1f..154e1576db 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -16,7 +16,16 @@ namespace osu.Game.Rulesets.Catch.UI { public class CatchPlayfield : ScrollingPlayfield { - public const float BASE_WIDTH = 512; + /// + /// The width of the playfield. + /// The horizontal movement of the catcher is confined in the area of this width. + /// + public const float WIDTH = 512; + + /// + /// The center position of the playfield. + /// + public const float CENTER_X = WIDTH / 2; internal readonly CatcherArea CatcherArea; @@ -26,22 +35,25 @@ namespace osu.Game.Rulesets.Catch.UI public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation) { - Container explodingFruitContainer; - - InternalChildren = new Drawable[] + var explodingFruitContainer = new Container { - explodingFruitContainer = new Container - { - RelativeSizeAxes = Axes.Both, - }, - CatcherArea = new CatcherArea(difficulty) - { - CreateDrawableRepresentation = createDrawableRepresentation, - ExplodingFruitTarget = explodingFruitContainer, - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - }, - HitObjectContainer + RelativeSizeAxes = Axes.Both, + }; + + CatcherArea = new CatcherArea(difficulty) + { + CreateDrawableRepresentation = createDrawableRepresentation, + ExplodingFruitTarget = explodingFruitContainer, + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + }; + + InternalChildren = new[] + { + explodingFruitContainer, + CatcherArea.MovableCatcher.CreateProxiedContent(), + HitObjectContainer, + CatcherArea }; } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs index b8d3dc9017..8ee23461ba 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Catch.UI { base.Update(); - Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.BASE_WIDTH); + Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.WIDTH); Size = Vector2.Divide(Vector2.One, Scale); } } diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 9cce46d730..8820dff730 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -5,12 +5,14 @@ using System; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Skinning; @@ -42,10 +44,16 @@ namespace osu.Game.Rulesets.Catch.UI /// /// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable. /// - public const double BASE_SPEED = 1.0 / 512; + public const double BASE_SPEED = 1.0; public Container ExplodingFruitTarget; + private Container caughtFruitContainer { get; } = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }; + [NotNull] private readonly Container trailsTarget; @@ -83,8 +91,6 @@ namespace osu.Game.Rulesets.Catch.UI /// private readonly float catchWidth; - private Container caughtFruit; - private CatcherSprite catcherIdle; private CatcherSprite catcherKiai; private CatcherSprite catcherFail; @@ -99,14 +105,12 @@ namespace osu.Game.Rulesets.Catch.UI private double hyperDashModifier = 1; private int hyperDashDirection; private float hyperDashTargetPosition; + private Bindable hitLighting; public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) { this.trailsTarget = trailsTarget; - RelativePositionAxes = Axes.X; - X = 0.5f; - Origin = Anchor.TopCentre; Size = new Vector2(CatcherArea.CATCHER_SIZE); @@ -117,15 +121,13 @@ namespace osu.Game.Rulesets.Catch.UI } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + hitLighting = config.GetBindable(OsuSetting.HitLighting); + InternalChildren = new Drawable[] { - caughtFruit = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }, + caughtFruitContainer, catcherIdle = new CatcherSprite(CatcherAnimationState.Idle) { Anchor = Anchor.TopCentre, @@ -148,6 +150,11 @@ namespace osu.Game.Rulesets.Catch.UI updateCatcher(); } + /// + /// Creates proxied content to be displayed beneath hitobjects. + /// + public Drawable CreateProxiedContent() => caughtFruitContainer.CreateProxy(); + /// /// Calculates the scale of the catcher based off the provided beatmap difficulty. /// @@ -179,7 +186,7 @@ namespace osu.Game.Rulesets.Catch.UI const float allowance = 10; - while (caughtFruit.Any(f => + while (caughtFruitContainer.Any(f => f.LifetimeEnd == double.MaxValue && Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) { @@ -190,13 +197,16 @@ namespace osu.Game.Rulesets.Catch.UI fruit.X = Math.Clamp(fruit.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); - caughtFruit.Add(fruit); + caughtFruitContainer.Add(fruit); - AddInternal(new HitExplosion(fruit) + if (hitLighting.Value) { - X = fruit.X, - Scale = new Vector2(fruit.HitObject.Scale) - }); + AddInternal(new HitExplosion(fruit) + { + X = fruit.X, + Scale = new Vector2(fruit.HitObject.Scale) + }); + } } /// @@ -209,8 +219,8 @@ namespace osu.Game.Rulesets.Catch.UI var halfCatchWidth = catchWidth * 0.5f; // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. - var catchObjectPosition = fruit.X * CatchPlayfield.BASE_WIDTH; - var catcherPosition = Position.X * CatchPlayfield.BASE_WIDTH; + var catchObjectPosition = fruit.X; + var catcherPosition = Position.X; var validCatch = catchObjectPosition >= catcherPosition - halfCatchWidth && @@ -224,7 +234,7 @@ namespace osu.Game.Rulesets.Catch.UI { var target = fruit.HyperDashTarget; var timeDifference = target.StartTime - fruit.StartTime; - double positionDifference = target.X * CatchPlayfield.BASE_WIDTH - catcherPosition; + double positionDifference = target.X - catcherPosition; var velocity = positionDifference / Math.Max(1.0, timeDifference - 1000.0 / 60.0); SetHyperDashState(Math.Abs(velocity), target.X); @@ -331,7 +341,7 @@ namespace osu.Game.Rulesets.Catch.UI public void UpdatePosition(float position) { - position = Math.Clamp(position, 0, 1); + position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); if (position == X) return; @@ -345,7 +355,7 @@ namespace osu.Game.Rulesets.Catch.UI /// public void Drop() { - foreach (var f in caughtFruit.ToArray()) + foreach (var f in caughtFruitContainer.ToArray()) Drop(f); } @@ -354,7 +364,7 @@ namespace osu.Game.Rulesets.Catch.UI /// public void Explode() { - foreach (var f in caughtFruit.ToArray()) + foreach (var f in caughtFruitContainer.ToArray()) Explode(f); } @@ -453,9 +463,9 @@ namespace osu.Game.Rulesets.Catch.UI if (ExplodingFruitTarget != null) { fruit.Anchor = Anchor.TopLeft; - fruit.Position = caughtFruit.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); + fruit.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); - if (!caughtFruit.Remove(fruit)) + if (!caughtFruitContainer.Remove(fruit)) // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. return; diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 37d177b936..4255c3b1af 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Catch.UI public Func> CreateDrawableRepresentation; + public readonly Catcher MovableCatcher; + public Container ExplodingFruitTarget { set => MovableCatcher.ExplodingFruitTarget = value; @@ -31,14 +33,8 @@ namespace osu.Game.Rulesets.Catch.UI public CatcherArea(BeatmapDifficulty difficulty = null) { - RelativeSizeAxes = Axes.X; - Height = CATCHER_SIZE; - Child = MovableCatcher = new Catcher(this, difficulty); - } - - public static float GetCatcherSize(BeatmapDifficulty difficulty) - { - return CATCHER_SIZE / CatchPlayfield.BASE_WIDTH * (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); + Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); + Child = MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }; } public void OnResult(DrawableCatchHitObject fruit, JudgementResult result) @@ -110,7 +106,5 @@ namespace osu.Game.Rulesets.Catch.UI if (state?.CatcherX != null) MovableCatcher.X = state.CatcherX.Value; } - - protected internal readonly Catcher MovableCatcher; } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs index d8f87195d1..c8feb4ae24 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCase("convert-samples")] [TestCase("mania-samples")] + [TestCase("slider-convert-samples")] public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) @@ -29,13 +30,16 @@ namespace osu.Game.Rulesets.Mania.Tests StartTime = hitObject.StartTime, EndTime = hitObject.GetEndTime(), Column = ((ManiaHitObject)hitObject).Column, - NodeSamples = getSampleNames((hitObject as HoldNote)?.NodeSamples) + Samples = getSampleNames(hitObject.Samples), + NodeSamples = getNodeSampleNames((hitObject as HoldNote)?.NodeSamples) }; } - private IList> getSampleNames(List> hitSampleInfo) - => hitSampleInfo?.Select(samples => - (IList)samples.Select(sample => sample.LookupNames.First()).ToList()) + private IList getSampleNames(IList hitSampleInfo) + => hitSampleInfo.Select(sample => sample.LookupNames.First()).ToList(); + + private IList> getNodeSampleNames(List> hitSampleInfo) + => hitSampleInfo?.Select(getSampleNames) .ToList(); protected override Ruleset CreateRuleset() => new ManiaRuleset(); @@ -51,14 +55,19 @@ namespace osu.Game.Rulesets.Mania.Tests public double StartTime; public double EndTime; public int Column; + public IList Samples; public IList> NodeSamples; public bool Equals(SampleConvertValue other) => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) && Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience) - && samplesEqual(NodeSamples, other.NodeSamples); + && samplesEqual(Samples, other.Samples) + && nodeSamplesEqual(NodeSamples, other.NodeSamples); - private static bool samplesEqual(ICollection> firstSampleList, ICollection> secondSampleList) + private static bool samplesEqual(ICollection firstSampleList, ICollection secondSampleList) + => firstSampleList.SequenceEqual(secondSampleList); + + private static bool nodeSamplesEqual(ICollection> firstSampleList, ICollection> secondSampleList) { if (firstSampleList == null && secondSampleList == null) return true; diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-stage-left.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png similarity index 100% rename from osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-stage-left.png rename to osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-stage-right.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png similarity index 100% rename from osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-stage-right.png rename to osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini index 941abac1da..36765d61bf 100644 --- a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini +++ b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini @@ -9,4 +9,6 @@ Hit50: mania/hit50 Hit100: mania/hit100 Hit200: mania/hit200 Hit300: mania/hit300 -Hit300g: mania/hit300g \ No newline at end of file +Hit300g: mania/hit300g +StageLeft: mania/stage-left +StageRight: mania/stage-right \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs index a692c0b697..0c56f7bcf4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs @@ -1,23 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Skinning; +using osu.Game.Rulesets.Objects; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests.Skinning { [TestFixture] public class TestSceneHitExplosion : ManiaSkinnableTestScene { + private readonly List> hitExplosionPools = new List>(); + public TestSceneHitExplosion() { int runcount = 0; @@ -29,28 +33,40 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning if (runcount % 15 > 12) return; - CreatedDrawables.OfType().ForEach(c => + int poolIndex = 0; + + foreach (var c in CreatedDrawables.OfType()) { - c.Add(new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, 0), - _ => new DefaultHitExplosion((runcount / 15) % 2 == 0 ? new Color4(94, 0, 57, 255) : new Color4(6, 84, 0, 255), runcount % 6 != 0) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - })); - }); + c.Add(hitExplosionPools[poolIndex].Get(e => + { + e.Apply(new JudgementResult(new HitObject(), runcount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement())); + + e.Anchor = Anchor.Centre; + e.Origin = Anchor.Centre; + })); + + poolIndex++; + } }, 100); } [BackgroundDependencyLoader] private void load() { - SetContents(() => new ColumnTestContainer(0, ManiaAction.Key1) + SetContents(() => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Y, - Y = -0.25f, - Size = new Vector2(Column.COLUMN_WIDTH, DefaultNotePiece.NOTE_HEIGHT), + var pool = new DrawablePool(5); + hitExplosionPools.Add(pool); + + return new ColumnTestContainer(0, ManiaAction.Key1) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Y, + Y = -0.25f, + Size = new Vector2(Column.COLUMN_WIDTH, DefaultNotePiece.NOTE_HEIGHT), + Child = pool + }; }); } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 0d13b85901..95072cf4f8 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -2,6 +2,7 @@ // 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.Screens; using osu.Game.Beatmaps; @@ -10,6 +11,8 @@ using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -236,6 +239,53 @@ namespace osu.Game.Rulesets.Mania.Tests assertTailJudgement(HitResult.Meh); } + [Test] + public void TestMissReleaseAndHitSecondRelease() + { + var windows = new ManiaHitWindows(); + windows.SetDifficulty(10); + + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = 1000, + Duration = 500, + Column = 0, + }, + new HoldNote + { + StartTime = 1000 + 500 + windows.WindowFor(HitResult.Miss) + 10, + Duration = 500, + Column = 0, + }, + }, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty + { + SliderTickRate = 4, + OverallDifficulty = 10, + }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + performTest(new List + { + new ManiaReplayFrame(beatmap.HitObjects[1].StartTime, ManiaAction.Key1), + new ManiaReplayFrame(beatmap.HitObjects[1].GetEndTime()), + }, beatmap); + + AddAssert("first hold note missed", () => judgementResults.Where(j => beatmap.HitObjects[0].NestedHitObjects.Contains(j.HitObject)) + .All(j => j.Type == HitResult.Miss)); + + AddAssert("second hold note missed", () => judgementResults.Where(j => beatmap.HitObjects[1].NestedHitObjects.Contains(j.HitObject)) + .All(j => j.Type == HitResult.Perfect)); + } + private void assertHeadJudgement(HitResult result) => AddAssert($"head judged as {result}", () => judgementResults[0].Type == result); @@ -250,11 +300,11 @@ namespace osu.Game.Rulesets.Mania.Tests private ScoreAccessibleReplayPlayer currentPlayer; - private void performTest(List frames) + private void performTest(List frames, Beatmap beatmap = null) { - AddStep("load player", () => + if (beatmap == null) { - Beatmap.Value = CreateWorkingBeatmap(new Beatmap + beatmap = new Beatmap { HitObjects = { @@ -270,9 +320,14 @@ namespace osu.Game.Rulesets.Mania.Tests BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 4 }, Ruleset = new ManiaRuleset().RulesetInfo }, - }); + }; - Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + } + + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs new file mode 100644 index 0000000000..8698ba3abd --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestScenePlayfieldCoveringContainer : OsuTestScene + { + private readonly ScrollingTestContainer scrollingContainer; + private readonly PlayfieldCoveringWrapper cover; + + public TestScenePlayfieldCoveringContainer() + { + Child = scrollingContainer = new ScrollingTestContainer(ScrollingDirection.Down) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 500), + Child = cover = new PlayfieldCoveringWrapper(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Orange + }) + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + [Test] + public void TestScrollingDownwards() + { + AddStep("set down scroll", () => scrollingContainer.Direction = ScrollingDirection.Down); + AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f); + AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f); + AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f); + } + + [Test] + public void TestScrollingUpwards() + { + AddStep("set up scroll", () => scrollingContainer.Direction = ScrollingDirection.Up); + AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f); + AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f); + AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index 77c871718b..972cbec4a2 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 9fbdf58e21..d03eb0b3c9 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -483,9 +483,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (!(HitObject is IHasPathWithRepeats curveData)) return null; - double segmentTime = (EndTime - HitObject.StartTime) / spanCount; - - int index = (int)(segmentTime == 0 ? 0 : (time - HitObject.StartTime) / segmentTime); + // mathematically speaking this should be a whole number always, but floating-point arithmetic is not so kind + var index = (int)Math.Round(SegmentDuration == 0 ? 0 : (time - HitObject.StartTime) / SegmentDuration, MidpointRounding.AwayFromZero); // avoid slicing the list & creating copies, if at all possible. return index == 0 ? curveData.NodeSamples : curveData.NodeSamples.Skip(index).ToList(); diff --git a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs index 53db676a54..53967ffa05 100644 --- a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Judgements return 300; case HitResult.Perfect: - return 320; + return 350; } } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 6ddb052585..68dce8b139 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -12,6 +12,7 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Replays.Types; @@ -30,9 +31,11 @@ using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Mania { + [ExcludeFromDynamicCompile] public class ManiaRuleset : Ruleset, ILegacyRuleset { /// @@ -309,6 +312,21 @@ namespace osu.Game.Rulesets.Mania { return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast().OrderByDescending(i => i).First(v => variant >= v); } + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + } + }; } public enum PlayfieldType diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index 4c125ad6ef..cbdcd49c5b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -1,23 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModFadeIn : Mod + public class ManiaModFadeIn : ManiaModHidden { public override string Name => "Fade In"; public override string Acronym => "FI"; public override IconUsage? Icon => OsuIcon.ModHidden; - public override ModType Type => ModType.DifficultyIncrease; public override string Description => @"Keys appear out of nowhere!"; - public override double ScoreMultiplier => 1; - public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index 66b90984b4..4bdb15526f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -2,15 +2,44 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModHidden : ModHidden + public class ManiaModHidden : ModHidden, IApplicableToDrawableRuleset { public override string Description => @"Keys fade out before you hit them!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + /// + /// The direction in which the cover should expand. + /// + protected virtual CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; + + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; + + foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) + { + HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; + Container hocParent = (Container)hoc.Parent; + + hocParent.Remove(hoc); + hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => + { + c.RelativeSizeAxes = Axes.Both; + c.Direction = ExpandDirection; + c.Coverage = 0.5f; + })); + } + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 2262bd2b7d..0c5289efe1 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -167,6 +167,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (action != Action.Value) return false; + // The tail has a lenience applied to it which is factored into the miss window (i.e. the miss judgement will be delayed). + // But the hold cannot ever be started within the late-lenience window, so we should skip trying to begin the hold during that time. + // Note: Unlike below, we use the tail's start time to determine the time offset. + if (Time.Current > Tail.HitObject.StartTime && !Tail.HitObject.HitWindows.CanBeHit(Time.Current - Tail.HitObject.StartTime)) + return false; + beginHoldAt(Time.Current - Head.HitObject.StartTime); Head.UpdateResult(); diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs b/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs index 899718b77e..aa0c148caf 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Mania.Replays protected override bool IsImportant(ManiaReplayFrame frame) => frame.Actions.Any(); - public override List GetPendingInputs() => new List { new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() } }; + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } } } diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json index b8ce85eef5..fec1360b26 100644 --- a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json @@ -9,7 +9,8 @@ ["normal-hitnormal"], ["soft-hitnormal"], ["drum-hitnormal"] - ] + ], + "Samples": ["drum-hitnormal"] }, { "StartTime": 1875.0, "EndTime": 2750.0, @@ -17,14 +18,16 @@ "NodeSamples": [ ["soft-hitnormal"], ["drum-hitnormal"] - ] + ], + "Samples": ["drum-hitnormal"] }] }, { "StartTime": 3750.0, "Objects": [{ "StartTime": 3750.0, "EndTime": 3750.0, - "Column": 3 + "Column": 3, + "Samples": ["normal-hitnormal"] }] }] } \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu index 16b73992d2..fea1de6614 100644 --- a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu @@ -13,4 +13,4 @@ SliderTickRate:1 [HitObjects] 88,99,1000,6,0,L|306:259,2,245,0|0|0,1:0|2:0|3:0,0:0:0:0: -259,118,3750,1,0,0:0:0:0: +259,118,3750,1,0,1:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json index e22540614d..1aca75a796 100644 --- a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json @@ -8,7 +8,8 @@ "NodeSamples": [ ["normal-hitnormal"], [] - ] + ], + "Samples": ["normal-hitnormal"] }] }, { "StartTime": 2000.0, @@ -19,7 +20,8 @@ "NodeSamples": [ ["drum-hitnormal"], [] - ] + ], + "Samples": ["drum-hitnormal"] }] }] } \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json new file mode 100644 index 0000000000..e3768a90d7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json @@ -0,0 +1,21 @@ +{ + "Mappings": [{ + "StartTime": 8470.0, + "Objects": [{ + "StartTime": 8470.0, + "EndTime": 8470.0, + "Column": 0, + "Samples": ["normal-hitnormal", "normal-hitclap"] + }, { + "StartTime": 8626.470587768974, + "EndTime": 8626.470587768974, + "Column": 1, + "Samples": ["normal-hitnormal"] + }, { + "StartTime": 8782.941175537948, + "EndTime": 8782.941175537948, + "Column": 2, + "Samples": ["normal-hitnormal", "normal-hitclap"] + }] + }] +} diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu new file mode 100644 index 0000000000..08e90ce807 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu @@ -0,0 +1,15 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.5 +SliderMultiplier:2.00000000596047 +SliderTickRate:1 + +[TimingPoints] +0,312.941176470588,4,1,0,100,1,0 + +[HitObjects] +82,216,8470,6,0,P|52:161|99:113,2,100,8|0|8,1:0|1:0|1:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 9b54b48de3..4b2f643333 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -7,6 +7,10 @@ namespace osu.Game.Rulesets.Mania.Scoring { internal class ManiaScoreProcessor : ScoreProcessor { + protected override double DefaultAccuracyPortion => 0.95; + + protected override double DefaultComboPortion => 0.05; + public override HitWindows CreateHitWindows() => new ManiaHitWindows(); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs index a749f80855..9f716428c0 100644 --- a/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; @@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Mania.Skinning string imageName = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage)?.Value ?? $"mania-note{FallbackColumnIndex}L"; - sprite = skin.GetAnimation(imageName, true, true).With(d => + sprite = skin.GetAnimation(imageName, WrapMode.ClampToEdge, WrapMode.ClampToEdge, true, true).With(d => { if (d == null) return; diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs index bc93bb2615..12747924de 100644 --- a/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs @@ -6,13 +6,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Mania.Skinning { - public class LegacyHitExplosion : LegacyManiaColumnElement + public class LegacyHitExplosion : LegacyManiaColumnElement, IHitExplosion { private readonly IBindable direction = new Bindable(); @@ -62,9 +64,9 @@ namespace osu.Game.Rulesets.Mania.Skinning explosion.Anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; } - protected override void LoadComplete() + public void Animate(JudgementResult result) { - base.LoadComplete(); + (explosion as IFramedAnimation)?.GotoFrame(0); explosion?.FadeInFromZero(80) .Then().FadeOut(120); diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs index 515c941d65..283b04373b 100644 --- a/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.UI.Scrolling; @@ -92,7 +93,7 @@ namespace osu.Game.Rulesets.Mania.Skinning string noteImage = GetColumnSkinConfig(skin, lookup)?.Value ?? $"mania-note{FallbackColumnIndex}{suffix}"; - return skin.GetTexture(noteImage); + return skin.GetTexture(noteImage, WrapMode.ClampToEdge, WrapMode.ClampToEdge); } } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 511d6c8623..255ce4c064 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -9,9 +9,9 @@ using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; @@ -33,11 +33,11 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable Action = new Bindable(); - private readonly ColumnHitObjectArea hitObjectArea; - + public readonly ColumnHitObjectArea HitObjectArea; internal readonly Container TopLevelContainer; + private readonly DrawablePool hitExplosionPool; - public Container UnderlayElements => hitObjectArea.UnderlayElements; + public Container UnderlayElements => HitObjectArea.UnderlayElements; public Column(int index) { @@ -53,9 +53,10 @@ namespace osu.Game.Rulesets.Mania.UI InternalChildren = new[] { + hitExplosionPool = new DrawablePool(5), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), - hitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, + HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, Index), _ => new DefaultKeyArea()) { RelativeSizeAxes = Axes.Both @@ -64,7 +65,7 @@ namespace osu.Game.Rulesets.Mania.UI TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; - TopLevelContainer.Add(hitObjectArea.Explosions.CreateProxy()); + TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); } public override Axes RelativeSizeAxes => Axes.Y; @@ -108,15 +109,7 @@ namespace osu.Game.Rulesets.Mania.UI if (!result.IsHit || !judgedObject.DisplayResult || !DisplayJudgements.Value) return; - var explosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, Index), _ => - new DefaultHitExplosion(judgedObject.AccentColour.Value, judgedObject is DrawableHoldNoteTick)) - { - RelativeSizeAxes = Axes.Both - }; - - hitObjectArea.Explosions.Add(explosion); - - explosion.Delay(200).Expire(true); + HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); } public bool OnPressed(ManiaAction action) diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs index ba5281a1a2..8f7880dafa 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; @@ -14,12 +15,14 @@ namespace osu.Game.Rulesets.Mania.UI.Components public class HitObjectArea : SkinReloadableDrawable { protected readonly IBindable Direction = new Bindable(); + public readonly HitObjectContainer HitObjectContainer; public HitObjectArea(HitObjectContainer hitObjectContainer) { - InternalChildren = new[] + InternalChild = new Container { - hitObjectContainer, + RelativeSizeAxes = Axes.Both, + Child = HitObjectContainer = hitObjectContainer }; } diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs index 7a047ed121..225269cf48 100644 --- a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs @@ -8,6 +8,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.UI.Scrolling; using osuTK; @@ -15,35 +17,36 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { - public class DefaultHitExplosion : CompositeDrawable + public class DefaultHitExplosion : CompositeDrawable, IHitExplosion { + private const float default_large_faint_size = 0.8f; + public override bool RemoveWhenNotAlive => true; + [Resolved] + private Column column { get; set; } + private readonly IBindable direction = new Bindable(); - private readonly CircularContainer largeFaint; - private readonly CircularContainer mainGlow1; + private CircularContainer largeFaint; + private CircularContainer mainGlow1; - public DefaultHitExplosion(Color4 objectColour, bool isSmall = false) + public DefaultHitExplosion() { Origin = Anchor.Centre; RelativeSizeAxes = Axes.X; Height = DefaultNotePiece.NOTE_HEIGHT; + } - // scale roughly in-line with visual appearance of notes - Scale = new Vector2(1f, 0.6f); - - if (isSmall) - Scale *= 0.5f; - + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { const float angle_variangle = 15; // should be less than 45 - const float roundness = 80; - const float initial_height = 10; - var colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1); + var colour = Interpolation.ValueAt(0.4f, column.AccentColour, Color4.White, 0, 1); InternalChildren = new Drawable[] { @@ -54,12 +57,12 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Both, Masking = true, // we want our size to be very small so the glow dominates it. - Size = new Vector2(0.8f), + Size = new Vector2(default_large_faint_size), Blending = BlendingParameters.Additive, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), + Colour = Interpolation.ValueAt(0.1f, column.AccentColour, Color4.White, 0, 1).Opacity(0.3f), Roundness = 160, Radius = 200, }, @@ -74,7 +77,7 @@ namespace osu.Game.Rulesets.Mania.UI EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), + Colour = Interpolation.ValueAt(0.6f, column.AccentColour, Color4.White, 0, 1), Roundness = 20, Radius = 50, }, @@ -114,30 +117,11 @@ namespace osu.Game.Rulesets.Mania.UI }, } }; - } - [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) - { direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); } - protected override void LoadComplete() - { - const double duration = 200; - - base.LoadComplete(); - - largeFaint - .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) - .FadeOut(duration * 2); - - mainGlow1.ScaleTo(1.4f, duration, Easing.OutQuint); - - this.FadeOut(duration, Easing.Out); - } - private void onDirectionChanged(ValueChangedEvent direction) { if (direction.NewValue == ScrollingDirection.Up) @@ -151,5 +135,29 @@ namespace osu.Game.Rulesets.Mania.UI Y = -DefaultNotePiece.NOTE_HEIGHT / 2; } } + + public void Animate(JudgementResult result) + { + // scale roughly in-line with visual appearance of notes + Vector2 scale = new Vector2(1, 0.6f); + + if (result.Judgement is HoldNoteTickJudgement) + scale *= 0.5f; + + this.ScaleTo(scale); + + largeFaint + .ResizeTo(default_large_faint_size) + .Then() + .ResizeTo(default_large_faint_size * new Vector2(5, 1), PoolableHitExplosion.DURATION, Easing.OutQuint) + .FadeOut(PoolableHitExplosion.DURATION * 2); + + mainGlow1 + .ScaleTo(1) + .Then() + .ScaleTo(1.4f, PoolableHitExplosion.DURATION, Easing.OutQuint); + + this.FadeOutFromOne(PoolableHitExplosion.DURATION, Easing.Out); + } } } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 8797f014df..d99f6cb8d3 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -15,6 +15,10 @@ namespace osu.Game.Rulesets.Mania.UI { } + public DrawableManiaJudgement() + { + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs new file mode 100644 index 0000000000..3252dcc276 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// Common interface for all hit explosion bodies. + /// + public interface IHitExplosion + { + /// + /// Begins animating this . + /// + /// The type of that caused this explosion. + void Animate(JudgementResult result); + } +} diff --git a/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs new file mode 100644 index 0000000000..15d216e8c5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// A that has its contents partially hidden by an adjustable "cover". This is intended to be used in a playfield. + /// + public class PlayfieldCoveringWrapper : CompositeDrawable + { + /// + /// The complete cover, including gradient and fill. + /// + private readonly Drawable cover; + + /// + /// The gradient portion of the cover. + /// + private readonly Box gradient; + + /// + /// The fully-opaque portion of the cover. + /// + private readonly Box filled; + + private readonly IBindable scrollDirection = new Bindable(); + + public PlayfieldCoveringWrapper(Drawable content) + { + InternalChild = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + content, + cover = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = new BlendingParameters + { + // Don't change the destination colour. + RGBEquation = BlendingEquation.Add, + Source = BlendingType.Zero, + Destination = BlendingType.One, + // Subtract the cover's alpha from the destination (points with alpha 1 should make the destination completely transparent). + AlphaEquation = BlendingEquation.Add, + SourceAlpha = BlendingType.Zero, + DestinationAlpha = BlendingType.OneMinusSrcAlpha + }, + Children = new Drawable[] + { + gradient = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Height = 0.25f, + Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(0f), + Color4.White.Opacity(1f) + ) + }, + filled = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = 0 + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + scrollDirection.BindTo(scrollingInfo.Direction); + scrollDirection.BindValueChanged(onScrollDirectionChanged, true); + } + + private void onScrollDirectionChanged(ValueChangedEvent direction) + => cover.Rotation = direction.NewValue == ScrollingDirection.Up ? 0 : 180f; + + /// + /// The relative area that should be completely covered. This does not include the fade. + /// + public float Coverage + { + set + { + filled.Height = value; + gradient.Y = -value; + } + } + + /// + /// The direction in which the cover expands. + /// + public CoverExpandDirection Direction + { + set => cover.Scale = value == CoverExpandDirection.AlongScroll ? Vector2.One : new Vector2(1, -1); + } + } + + public enum CoverExpandDirection + { + /// + /// The cover expands along the scrolling direction. + /// + AlongScroll, + + /// + /// The cover expands against the scrolling direction. + /// + AgainstScroll + } +} diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs new file mode 100644 index 0000000000..64b7d7d550 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.UI +{ + public class PoolableHitExplosion : PoolableDrawable + { + public const double DURATION = 200; + + public JudgementResult Result { get; private set; } + + [Resolved] + private Column column { get; set; } + + private SkinnableDrawable skinnableExplosion; + + public PoolableHitExplosion() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, column.Index), _ => new DefaultHitExplosion()) + { + RelativeSizeAxes = Axes.Both + }; + } + + public void Apply(JudgementResult result) + { + Result = result; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + (skinnableExplosion?.Drawable as IHitExplosion)?.Animate(Result); + + this.Delay(DURATION).Then().Expire(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index faa04dea97..36780b0f80 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -33,8 +34,8 @@ namespace osu.Game.Rulesets.Mania.UI public IReadOnlyList Columns => columnFlow.Children; private readonly FillFlowContainer columnFlow; - public Container Judgements => judgements; private readonly JudgementContainer judgements; + private readonly DrawablePool judgementPool; private readonly Drawable barLineContainer; private readonly Container topLevelContainer; @@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Mania.UI InternalChildren = new Drawable[] { + judgementPool = new DrawablePool(2), new Container { Anchor = Anchor.TopCentre, @@ -208,12 +210,14 @@ namespace osu.Game.Rulesets.Mania.UI if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; - judgements.Clear(); - judgements.Add(new DrawableManiaJudgement(result, judgedObject) + judgements.Clear(false); + judgements.Add(judgementPool.Get(j => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }); + j.Apply(result, judgedObject); + + j.Anchor = Anchor.Centre; + j.Origin = Anchor.Centre; + })); } protected override void Update() diff --git a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs index a0a38fc47b..cad98185ce 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs @@ -1,12 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { public abstract class OsuSkinnableTestScene : SkinnableTestScene { + private Container content; + + protected override Container Content + { + get + { + if (content == null) + base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); + + return content; + } + } + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png new file mode 100644 index 0000000000..3811e5050f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png new file mode 100644 index 0000000000..d84eab2f15 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png new file mode 100644 index 0000000000..4dd4a6d319 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png new file mode 100644 index 0000000000..c66f1c9309 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png new file mode 100644 index 0000000000..33902186d9 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png new file mode 100644 index 0000000000..6882a232e0 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png new file mode 100644 index 0000000000..98a9991c2f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav new file mode 100644 index 0000000000..5e583e77aa Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav new file mode 100644 index 0000000000..bba19381f1 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs new file mode 100644 index 0000000000..10d9d7ffde --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs @@ -0,0 +1,132 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Scoring; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneAccuracyHeatmap : OsuManualInputManagerTestScene + { + private Box background; + private Drawable object1; + private Drawable object2; + private TestAccuracyHeatmap accuracyHeatmap; + private ScheduledDelegate automaticAdditionDelegate; + + [SetUp] + public void Setup() => Schedule(() => + { + automaticAdditionDelegate?.Cancel(); + automaticAdditionDelegate = null; + + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333"), + }, + object1 = new BorderCircle + { + Position = new Vector2(256, 192), + Colour = Color4.Yellow, + }, + object2 = new BorderCircle + { + Position = new Vector2(100, 300), + }, + accuracyHeatmap = new TestAccuracyHeatmap(new ScoreInfo { Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(130) + } + }; + }); + + [Test] + public void TestManyHitPointsAutomatic() + { + AddStep("add scheduled delegate", () => + { + automaticAdditionDelegate = Scheduler.AddDelayed(() => + { + var randomPos = new Vector2( + RNG.NextSingle(object1.DrawPosition.X - object1.DrawSize.X / 2, object1.DrawPosition.X + object1.DrawSize.X / 2), + RNG.NextSingle(object1.DrawPosition.Y - object1.DrawSize.Y / 2, object1.DrawPosition.Y + object1.DrawSize.Y / 2)); + + // The background is used for ToLocalSpace() since we need to go _inside_ the DrawSizePreservingContainer (Content of TestScene). + accuracyHeatmap.AddPoint(object2.Position, object1.Position, randomPos, RNG.NextSingle(10, 500)); + InputManager.MoveMouseTo(background.ToScreenSpace(randomPos)); + }, 1, true); + }); + + AddWaitStep("wait for some hit points", 10); + } + + [Test] + public void TestManualPlacement() + { + AddStep("return user input", () => InputManager.UseParentInput = true); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + accuracyHeatmap.AddPoint(object2.Position, object1.Position, background.ToLocalSpace(e.ScreenSpaceMouseDownPosition), 50); + return true; + } + + private class TestAccuracyHeatmap : AccuracyHeatmap + { + public TestAccuracyHeatmap(ScoreInfo score) + : base(score, new TestBeatmap(new OsuRuleset().RulesetInfo)) + { + } + + public new void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius) + => base.AddPoint(start, end, hitPoint, radius); + } + + private class BorderCircle : CircularContainer + { + public BorderCircle() + { + Origin = Anchor.Centre; + Size = new Vector2(100); + + Masking = true; + BorderThickness = 2; + BorderColour = Color4.White; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(4), + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 46769f65fe..dde02e873b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing.Input; using osu.Game.Audio; @@ -79,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Tests public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); - public Texture GetTexture(string componentName) + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { switch (componentName) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index c81edf4e07..646f12f710 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -2,29 +2,111 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneDrawableJudgement : OsuSkinnableTestScene { + [Resolved] + private OsuConfigManager config { get; set; } + + private readonly List> pools; + public TestSceneDrawableJudgement() { + pools = new List>(); + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).OfType().Skip(1)) + showResult(result); + } + + [Test] + public void TestHitLightingDisabled() + { + AddStep("hit lighting disabled", () => config.Set(OsuSetting.HitLighting, false)); + + 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)); + } + + [Test] + public void TestHitLightingEnabled() + { + AddStep("hit lighting enabled", () => config.Set(OsuSetting.HitLighting, true)); + + 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)); + } + + private void showResult(HitResult result) + { + AddStep("Show " + result.GetDescription(), () => { - AddStep("Show " + result.GetDescription(), () => SetContents(() => - new DrawableOsuJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) + int poolIndex = 0; + + SetContents(() => + { + DrawablePool pool; + + if (poolIndex >= pools.Count) + pools.Add(pool = new DrawablePool(1)); + else { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - })); - } + pool = pools[poolIndex]; + + // We need to make sure neither the pool nor the judgement get disposed when new content is set, and they both share the same parent. + ((Container)pool.Parent).Clear(false); + } + + var container = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + pool, + pool.Get(j => j.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null)).With(j => + { + j.Anchor = Anchor.Centre; + j.Origin = Anchor.Centre; + }) + } + }; + + poolIndex++; + return container; + }); + }); + } + + private class TestDrawableOsuJudgement : DrawableOsuJudgement + { + public new SkinnableSprite Lighting => base.Lighting; + public new Container JudgementBody => base.JudgementBody; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 38c2bb9b95..461779b185 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -5,7 +5,9 @@ using System; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Testing.Input; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play; @@ -24,9 +26,34 @@ namespace osu.Game.Rulesets.Osu.Tests [Resolved] private OsuConfigManager config { get; set; } + private Drawable background; + public TestSceneGameplayCursor() { gameplayBeatmap = new GameplayBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + + AddStep("change background colour", () => + { + background?.Expire(); + + Add(background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) + }); + }); + + AddSliderStep("circle size", 0f, 10f, 0f, val => + { + config.Set(OsuSetting.AutoCursorSize, true); + gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val; + Scheduler.AddOnce(recreate); + }); + + AddStep("test cursor container", recreate); + + void recreate() => SetContents(() => new OsuInputManager(new OsuRuleset().RulesetInfo) { Child = new OsuCursorContainer() }); } [TestCase(1, 1)] @@ -69,16 +96,27 @@ namespace osu.Game.Rulesets.Osu.Tests private class ClickingCursorContainer : OsuCursorContainer { + private bool pressed; + + public bool Pressed + { + set + { + if (value == pressed) + return; + + pressed = value; + if (value) + OnPressed(OsuAction.LeftButton); + else + OnReleased(OsuAction.LeftButton); + } + } + protected override void Update() { base.Update(); - - double currentTime = Time.Current; - - if (((int)(currentTime / 1000)) % 2 == 0) - OnPressed(OsuAction.LeftButton); - else - OnReleased(OsuAction.LeftButton); + Pressed = ((int)(Time.Current / 1000)) % 2 == 0; } } @@ -87,6 +125,7 @@ namespace osu.Game.Rulesets.Osu.Tests public MovingCursorInputManager() { UseParentInput = false; + ShowVisualCursorGuide = false; } protected override void Update() diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs index c3b4d2625e..854626d362 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs @@ -223,7 +223,7 @@ namespace osu.Game.Rulesets.Osu.Tests const double time_slider = 1500; const double time_circle = 1510; Vector2 positionCircle = Vector2.Zero; - Vector2 positionSlider = new Vector2(80); + Vector2 positionSlider = new Vector2(30); var hitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index b357e20ee8..075bf314bc 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -9,6 +9,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Timing; @@ -131,7 +132,7 @@ namespace osu.Game.Rulesets.Osu.Tests }; } - public Texture GetTexture(string componentName) => null; + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public SampleChannel GetSample(ISampleInfo sampleInfo) => null; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index a9404f665a..6a689a1f80 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -26,19 +25,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneSlider : OsuSkinnableTestScene { - private Container content; - - protected override Container Content - { - get - { - if (content == null) - base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); - - return content; - } - } - private int depthIndex; public TestSceneSlider() diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 65bed071cd..47b3926ceb 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -4,57 +4,76 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; 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.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSpinner : OsuTestScene + public class TestSceneSpinner : OsuSkinnableTestScene { - private readonly Container content; - protected override Container Content => content; - private int depthIndex; - public TestSceneSpinner() - { - base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); + private TestDrawableSpinner drawableSpinner; - AddStep("Miss Big", () => testSingle(2)); - AddStep("Miss Medium", () => testSingle(5)); - AddStep("Miss Small", () => testSingle(7)); - AddStep("Hit Big", () => testSingle(2, true)); - AddStep("Hit Medium", () => testSingle(5, true)); - AddStep("Hit Small", () => testSingle(7, true)); + [TestCase(false)] + [TestCase(true)] + public void TestVariousSpinners(bool autoplay) + { + string term = autoplay ? "Hit" : "Miss"; + AddStep($"{term} Big", () => SetContents(() => testSingle(2, autoplay))); + AddStep($"{term} Medium", () => SetContents(() => testSingle(5, autoplay))); + AddStep($"{term} Small", () => SetContents(() => testSingle(7, autoplay))); } - private void testSingle(float circleSize, bool auto = false) + [TestCase(false)] + [TestCase(true)] + public void TestLongSpinner(bool autoplay) { - var spinner = new Spinner { StartTime = Time.Current + 1000, EndTime = Time.Current + 4000 }; + AddStep("Very short spinner", () => SetContents(() => testSingle(5, autoplay, 2000))); + AddUntilStep("Wait for completion", () => drawableSpinner.Result.HasResult); + AddUntilStep("Check correct progress", () => drawableSpinner.Progress == (autoplay ? 1 : 0)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestSuperShortSpinner(bool autoplay) + { + AddStep("Very short spinner", () => SetContents(() => testSingle(5, autoplay, 200))); + AddUntilStep("Wait for completion", () => drawableSpinner.Result.HasResult); + AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1); + } + + private Drawable testSingle(float circleSize, bool auto = false, double length = 3000) + { + const double delay = 2000; + + var spinner = new Spinner + { + StartTime = Time.Current + delay, + EndTime = Time.Current + delay + length + }; spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); - var drawable = new TestDrawableSpinner(spinner, auto) + drawableSpinner = new TestDrawableSpinner(spinner, auto) { Anchor = Anchor.Centre, Depth = depthIndex++ }; foreach (var mod in SelectedMods.Value.OfType()) - mod.ApplyToDrawableHitObjects(new[] { drawable }); + mod.ApplyToDrawableHitObjects(new[] { drawableSpinner }); - Add(drawable); + return drawableSpinner; } private class TestDrawableSpinner : DrawableSpinner { - private bool auto; + private readonly bool auto; public TestDrawableSpinner(Spinner s, bool auto) : base(s) @@ -62,16 +81,11 @@ namespace osu.Game.Rulesets.Osu.Tests this.auto = auto; } - protected override void CheckForResult(bool userTriggered, double timeOffset) + protected override void Update() { - if (auto && !userTriggered && Time.Current > Spinner.StartTime + Spinner.Duration / 2 && Progress < 1) - { - // force completion only once to not break human interaction - Disc.RotationAbsolute = Spinner.SpinsRequired * 360; - auto = false; - } - - base.CheckForResult(userTriggered, timeOffset); + base.Update(); + if (auto) + RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 3)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index ea006ec607..b46964e8b7 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -1,20 +1,29 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Utils; +using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Replays; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osuTK; -using System.Collections.Generic; -using System.Linq; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK; using static osu.Game.Tests.Visual.OsuTestScene.ClockBackedTestWorkingBeatmap; namespace osu.Game.Rulesets.Osu.Tests @@ -28,6 +37,8 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool Autoplay => true; + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) { var working = new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); @@ -36,6 +47,7 @@ namespace osu.Game.Rulesets.Osu.Tests } private DrawableSpinner drawableSpinner; + private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); [SetUpSteps] public override void SetUpSteps() @@ -49,24 +61,124 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestSpinnerRewindingRotation() { + double trackerRotationTolerance = 0; + addSeekStep(5000); - AddAssert("is rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Disc.RotationAbsolute, 0, 100)); + AddStep("calculate rotation tolerance", () => + { + 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.CumulativeRotation, 0, 100)); addSeekStep(0); - AddAssert("is rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Disc.RotationAbsolute, 0, 100)); + AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, trackerRotationTolerance)); + AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.CumulativeRotation, 0, 100)); } [Test] public void TestSpinnerMiddleRewindingRotation() { - double estimatedRotation = 0; + double finalCumulativeTrackerRotation = 0; + double finalTrackerRotation = 0, trackerRotationTolerance = 0; + double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; addSeekStep(5000); - AddStep("retrieve rotation", () => estimatedRotation = drawableSpinner.Disc.RotationAbsolute); + AddStep("retrieve disc rotation", () => + { + finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; + trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f); + }); + AddStep("retrieve spinner symbol rotation", () => + { + finalSpinnerSymbolRotation = spinnerSymbol.Rotation; + spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); + }); + AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.RotationTracker.CumulativeRotation); addSeekStep(2500); + AddAssert("disc rotation rewound", + // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. + // due to the exponential damping applied we're allowing a larger margin of error of about 10% + // (5% relative to the final rotation value, but we're half-way through the spin). + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation / 2, trackerRotationTolerance)); + AddAssert("symbol rotation rewound", + () => 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.CumulativeRotation, finalCumulativeTrackerRotation / 2, 100)); + addSeekStep(5000); - AddAssert("is rotation absolute almost same", () => Precision.AlmostEquals(drawableSpinner.Disc.RotationAbsolute, estimatedRotation, 100)); + AddAssert("is disc rotation almost same", + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); + AddAssert("is symbol rotation almost same", + () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, spinnerSymbolRotationTolerance)); + AddAssert("is cumulative rotation almost same", + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.CumulativeRotation, finalCumulativeTrackerRotation, 100)); + } + + [Test] + public void TestRotationDirection([Values(true, false)] bool clockwise) + { + if (clockwise) + { + AddStep("flip replay", () => + { + var drawableRuleset = this.ChildrenOfType().Single(); + var score = drawableRuleset.ReplayScore; + var scoreWithFlippedReplay = new Score + { + ScoreInfo = score.ScoreInfo, + Replay = flipReplay(score.Replay) + }; + drawableRuleset.SetReplayScore(scoreWithFlippedReplay); + }); + } + + addSeekStep(5000); + + AddAssert("disc spin direction correct", () => clockwise ? drawableSpinner.RotationTracker.Rotation > 0 : drawableSpinner.RotationTracker.Rotation < 0); + AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); + } + + private Replay flipReplay(Replay scoreReplay) => new Replay + { + Frames = scoreReplay + .Frames + .Cast() + .Select(replayFrame => + { + var flippedPosition = new Vector2(OsuPlayfield.BASE_SIZE.X - replayFrame.Position.X, replayFrame.Position.Y); + return new OsuReplayFrame(replayFrame.Time, flippedPosition, replayFrame.Actions.ToArray()); + }) + .Cast() + .ToList() + }; + + [Test] + public void TestSpinnerNormalBonusRewinding() + { + addSeekStep(1000); + + AddAssert("player score matching expected bonus score", () => + { + // multipled by 2 to nullify the score multiplier. (autoplay mod selected) + var totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; + return totalScore == (int)(drawableSpinner.RotationTracker.CumulativeRotation / 360) * SpinnerTick.SCORE_PER_TICK; + }); + + addSeekStep(0); + + AddAssert("player score is 0", () => ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value == 0); + } + + [Test] + public void TestSpinnerCompleteBonusRewinding() + { + addSeekStep(2500); + addSeekStep(0); + + AddAssert("player score is 0", () => ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value == 0); } [Test] @@ -74,13 +186,13 @@ namespace osu.Game.Rulesets.Osu.Tests { double estimatedSpm = 0; - addSeekStep(2500); + addSeekStep(1000); AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpmCounter.SpinsPerMinute); - addSeekStep(5000); + addSeekStep(2000); AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0)); - addSeekStep(2500); + addSeekStep(1000); AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0)); } @@ -100,12 +212,17 @@ namespace osu.Game.Rulesets.Osu.Tests Position = new Vector2(256, 192), EndTime = 6000, }, - // placeholder object to avoid hitting the results screen - new HitCircle - { - StartTime = 99999, - } } }; + + private class ScoreExposedPlayer : TestPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public ScoreExposedPlayer() + : base(false, false) + { + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index 2fcfa1deb7..d6a68abaf2 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs new file mode 100644 index 0000000000..9b33e746b3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class OsuHitCircleJudgementResult : OsuJudgementResult + { + /// + /// The . + /// + public HitCircle HitCircle => (HitCircle)HitObject; + + /// + /// The position of the player's cursor when was hit. + /// + public Vector2? CursorPositionAtHit; + + public OsuHitCircleJudgementResult(HitObject hitObject, Judgement judgement) + : base(hitObject, judgement) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index d75f4c70d7..2263e2b2f4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Osu.Mods { @@ -30,6 +31,8 @@ namespace osu.Game.Rulesets.Osu.Mods private OsuInputManager inputManager; + private GameplayClock gameplayClock; + private List replayFrames; private int currentFrame; @@ -38,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Mods { if (currentFrame == replayFrames.Count - 1) return; - double time = playfield.Time.Current; + double time = gameplayClock.CurrentTime; // Very naive implementation of autopilot based on proximity to replay frames. // TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered). @@ -53,6 +56,8 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { + gameplayClock = drawableRuleset.FrameStableClock; + // Grab the input manager to disable the user's cursor, and for future use inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; inputManager.AllowUserCursorMovement = false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index 73cb483ef0..ee6a7815e2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Osu.Mods { @@ -15,6 +17,14 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Description => "Hit them at the right size!"; - protected override float StartScale => 2f; + [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] + public override BindableNumber StartScale { get; } = new BindableFloat + { + MinValue = 1f, + MaxValue = 25f, + Default = 2f, + Value = 2f, + Precision = 0.1f, + }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 8228161008..ff995e38ce 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Mods public BindableNumber CircleSize { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Mods public BindableNumber ApproachRate { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index f08d4e8f5e..182d6eeb4b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Osu.Mods { @@ -15,6 +17,14 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Description => "Hit them at the right size!"; - protected override float StartScale => 0.5f; + [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] + public override BindableNumber StartScale { get; } = new BindableFloat + { + MinValue = 0f, + MaxValue = 0.99f, + Default = 0.5f, + Value = 0.5f, + Precision = 0.01f, + }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index fdba03f260..08fd13915d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -82,9 +82,7 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableSpinner spinner: // hide elements we don't care about. - spinner.Disc.Hide(); - spinner.Ticks.Hide(); - spinner.Background.Hide(); + // todo: hide background using (spinner.BeginAbsoluteSequence(fadeOutStartTime + longFadeDuration, true)) spinner.FadeOut(fadeOutDuration); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs index 42ddddc4dd..06ba4cde4a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override double ScoreMultiplier => 1; - protected virtual float StartScale => 1; + public abstract BindableNumber StartScale { get; } protected virtual float EndScale => 1; @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableHitCircle _: { using (drawable.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) - drawable.ScaleTo(StartScale).Then().ScaleTo(EndScale, h.TimePreempt, Easing.OutSine); + drawable.ScaleTo(StartScale.Value).Then().ScaleTo(EndScale, h.TimePreempt, Easing.OutSine); break; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index 7b54baa99b..47d765fecd 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -40,8 +40,8 @@ namespace osu.Game.Rulesets.Osu.Mods { var spinner = (DrawableSpinner)drawable; - spinner.Disc.Tracking = true; - spinner.Disc.Rotate(MathUtils.RadiansToDegrees((float)spinner.Clock.ElapsedFrameTime * 0.03f)); + spinner.RotationTracker.Tracking = true; + spinner.RotationTracker.AddRotation(MathUtils.RadiansToDegrees((float)spinner.Clock.ElapsedFrameTime * 0.03f)); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index 774f9cf58b..d7582f3196 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -43,6 +43,8 @@ namespace osu.Game.Rulesets.Osu.Mods var h = drawableOsu.HitObject; + //todo: expose and hide spinner background somehow + switch (drawable) { case DrawableHitCircle circle: @@ -56,11 +58,6 @@ namespace osu.Game.Rulesets.Osu.Mods slider.Body.OnSkinChanged += () => applySliderState(slider); applySliderState(slider); break; - - case DrawableSpinner spinner: - spinner.Disc.Hide(); - spinner.Background.Hide(); - break; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index d73ad888f4..a438dc8be4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -7,12 +7,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Scoring; -using osuTK; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -32,6 +35,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; + private InputManager inputManager; + public DrawableHitCircle(HitCircle h) : base(h) { @@ -86,6 +91,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables AccentColour.BindValueChanged(accent => ApproachCircle.Colour = accent.NewValue, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + public override double LifetimeStart { get => base.LifetimeStart; @@ -126,7 +138,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return; } - ApplyResult(r => r.Type = result); + ApplyResult(r => + { + var circleResult = (OsuHitCircleJudgementResult)r; + + // Todo: This should also consider misses, but they're a little more interesting to handle, since we don't necessarily know the position at the time of a miss. + if (result != HitResult.Miss) + { + var localMousePosition = ToLocalSpace(inputManager.CurrentState.Mouse.Position); + circleResult.CursorPositionAtHit = HitObject.StackedPosition + (localMousePosition - DrawSize / 2); + } + + circleResult.Type = result; + }); } protected override void UpdateInitialTransforms() @@ -172,6 +196,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public Drawable ProxiedLayer => ApproachCircle; + protected override JudgementResult CreateResult(Judgement judgement) => new OsuHitCircleJudgementResult(HitObject, judgement); + public class HitReceptor : CompositeDrawable, IKeyBindingHandler { // IsHovered is used diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 022e9ea12b..012d9f8878 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -16,52 +16,87 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableOsuJudgement : DrawableJudgement { - private SkinnableSprite lighting; + protected SkinnableSprite Lighting; + private Bindable lightingColour; + [Resolved] + private OsuConfigManager config { get; set; } + public DrawableOsuJudgement(JudgementResult result, DrawableHitObject judgedObject) : base(result, judgedObject) { } - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + public DrawableOsuJudgement() { - if (config.Get(OsuSetting.HitLighting) && Result.Type != HitResult.Miss) - { - AddInternal(lighting = new SkinnableSprite("lighting") - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Blending = BlendingParameters.Additive, - Depth = float.MaxValue - }); + } - if (JudgedObject != null) - { - lightingColour = JudgedObject.AccentColour.GetBoundCopy(); - lightingColour.BindValueChanged(colour => lighting.Colour = colour.NewValue, true); - } - else - { - lighting.Colour = Color4.White; - } + [BackgroundDependencyLoader] + private void load() + { + AddInternal(Lighting = new SkinnableSprite("lighting") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Depth = float.MaxValue, + Alpha = 0 + }); + } + + 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 double FadeOutDelay => lighting == null ? base.FadeOutDelay : 1400; + protected override void PrepareForUse() + { + base.PrepareForUse(); + + lightingColour?.UnbindAll(); + + Lighting.ResetAnimation(); + + if (JudgedObject != null) + { + lightingColour = JudgedObject.AccentColour.GetBoundCopy(); + lightingColour.BindValueChanged(colour => Lighting.Colour = Result.Type == HitResult.Miss ? Color4.Transparent : colour.NewValue, true); + } + else + { + Lighting.Colour = Color4.White; + } + } + + private double fadeOutDelay; + protected override double FadeOutDelay => fadeOutDelay; protected override void ApplyHitAnimations() { - if (lighting != null) - { - JudgementBody.Delay(FadeInDuration).FadeOut(400); + bool hitLightingEnabled = config.Get(OsuSetting.HitLighting); - lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); - lighting.FadeIn(200).Then().Delay(200).FadeOut(1000); + if (hitLightingEnabled) + { + JudgementBody.FadeIn().Delay(FadeInDuration).FadeOut(400); + + Lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); + Lighting.FadeIn(200).Then().Delay(200).FadeOut(1000); + } + else + { + JudgementBody.Alpha = 1; } - JudgementText?.TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); + fadeOutDelay = hitLightingEnabled ? 1400 : base.FadeOutDelay; + + JudgementText?.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); base.ApplyHitAnimations(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 72502c02cd..07f40f763b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osuTK; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; @@ -11,6 +12,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; using osuTK.Graphics; using osu.Game.Skinning; @@ -81,6 +83,42 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables foreach (var drawableHitObject in NestedHitObjects) drawableHitObject.AccentColour.Value = colour.NewValue; }, true); + + Tracking.BindValueChanged(updateSlidingSample); + } + + private SkinnableSound slidingSample; + + protected override void LoadSamples() + { + base.LoadSamples(); + + slidingSample?.Expire(); + slidingSample = null; + + var firstSample = HitObject.Samples.FirstOrDefault(); + + if (firstSample != null) + { + var clone = HitObject.SampleControlPoint.ApplyTo(firstSample); + clone.Name = "sliderslide"; + + AddInternal(slidingSample = new SkinnableSound(clone) + { + Looping = true + }); + } + } + + private void updateSlidingSample(ValueChangedEvent tracking) + { + // note that samples will not start playing if exiting a seek operation in the middle of a slider. + // may be something we want to address at a later point, but not so easy to make happen right now + // (SkinnableSound would need to expose whether the sample is already playing and this logic would need to run in Update). + if (tracking.NewValue && ShouldPlaySamples) + slidingSample?.Play(); + else + slidingSample?.Stop(); } protected override void AddNestedHitObject(DrawableHitObject hitObject) @@ -156,6 +194,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Tracking.Value = Ball.Tracking; + if (Tracking.Value && slidingSample != null) + // keep the sliding sample playing at the current tracking position + slidingSample.Balance.Value = CalculateSamplePlaybackBalance(Ball.X / OsuPlayfield.BASE_SIZE.X); + double completionProgress = Math.Clamp((Time.Current - slider.StartTime) / slider.Duration, 0, 1); Ball.UpdateProgress(completionProgress); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 720ffcd51c..d79ecb7b4e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -23,6 +23,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private readonly Drawable scaleContainer; + public override bool DisplayResult => false; + public DrawableSliderRepeat(SliderRepeat sliderRepeat, DrawableSlider drawableSlider) : base(sliderRepeat) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 3c8ab0f5ab..7363da0de8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -3,19 +3,18 @@ using System; using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Ranking; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -23,27 +22,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { protected readonly Spinner Spinner; - public readonly SpinnerDisc Disc; - public readonly SpinnerTicks Ticks; + private readonly Container ticks; + + public readonly SpinnerRotationTracker RotationTracker; public readonly SpinnerSpmCounter SpmCounter; - - private readonly Container mainContainer; - - public readonly SpinnerBackground Background; - private readonly Container circleContainer; - private readonly CirclePiece circle; - private readonly GlowPiece glow; - - private readonly SpriteIcon symbol; - - private readonly Color4 baseColour = Color4Extensions.FromHex(@"002c3c"); - private readonly Color4 fillColour = Color4Extensions.FromHex(@"005b7c"); + private readonly SpinnerBonusDisplay bonusDisplay; private readonly IBindable positionBindable = new Bindable(); - private Color4 normalColour; - private Color4 completeColour; - public DrawableSpinner(Spinner s) : base(s) { @@ -52,62 +38,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Both; - // we are slightly bigger than our parent, to clip the top and bottom of the circle - Height = 1.3f; - Spinner = s; InternalChildren = new Drawable[] { - circleContainer = new Container - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - glow = new GlowPiece(), - circle = new CirclePiece - { - Position = Vector2.Zero, - Anchor = Anchor.Centre, - }, - new RingPiece(), - symbol = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(48), - Icon = FontAwesome.Solid.Asterisk, - Shadow = false, - }, - } - }, - mainContainer = new AspectContainer + ticks = new Container(), + new AspectContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, - Children = new[] + Children = new Drawable[] { - Background = new SpinnerBackground - { - Alpha = 0.6f, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - Disc = new SpinnerDisc(Spinner) - { - Scale = Vector2.Zero, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - circleContainer.CreateProxy(), - Ticks = new SpinnerTicks - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, + new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinnerDisc()), + RotationTracker = new SpinnerRotationTracker(Spinner) } }, SpmCounter = new SpinnerSpmCounter @@ -116,51 +60,147 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre, Y = 120, Alpha = 0 + }, + bonusDisplay = new SpinnerBonusDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -120, } }; } + private Bindable isSpinning; + + protected override void LoadComplete() + { + base.LoadComplete(); + + isSpinning = RotationTracker.IsSpinning.GetBoundCopy(); + isSpinning.BindValueChanged(updateSpinningSample); + } + + private SkinnableSound spinningSample; + + private const float minimum_volume = 0.0001f; + + protected override void LoadSamples() + { + base.LoadSamples(); + + spinningSample?.Expire(); + spinningSample = null; + + var firstSample = HitObject.Samples.FirstOrDefault(); + + if (firstSample != null) + { + var clone = HitObject.SampleControlPoint.ApplyTo(firstSample); + clone.Name = "spinnerspin"; + + AddInternal(spinningSample = new SkinnableSound(clone) + { + Volume = { Value = minimum_volume }, + Looping = true, + }); + } + } + + private void updateSpinningSample(ValueChangedEvent tracking) + { + // note that samples will not start playing if exiting a seek operation in the middle of a spinner. + // may be something we want to address at a later point, but not so easy to make happen right now + // (SkinnableSound would need to expose whether the sample is already playing and this logic would need to run in Update). + if (tracking.NewValue && ShouldPlaySamples) + { + spinningSample?.Play(); + spinningSample?.VolumeTo(1, 200); + } + else + { + spinningSample?.VolumeTo(minimum_volume, 200).Finally(_ => spinningSample.Stop()); + } + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableSpinnerTick tick: + ticks.Add(tick); + break; + } + } + + protected override void UpdateStateTransforms(ArmedState state) + { + base.UpdateStateTransforms(state); + + using (BeginDelayedSequence(Spinner.Duration, true)) + this.FadeOut(160); + + // skin change does a rewind of transforms, which will stop the spinning sound from playing if it's currently in playback. + isSpinning?.TriggerChange(); + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + ticks.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case SpinnerBonusTick bonusTick: + return new DrawableSpinnerBonusTick(bonusTick); + + case SpinnerTick tick: + return new DrawableSpinnerTick(tick); + } + + return base.CreateNestedHitObject(hitObject); + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { - normalColour = baseColour; - - Background.AccentColour = normalColour; - - completeColour = colours.YellowLight.Opacity(0.75f); - - Disc.AccentColour = fillColour; - circle.Colour = colours.BlueDark; - glow.Colour = colours.BlueDark; - positionBindable.BindValueChanged(pos => Position = pos.NewValue); positionBindable.BindTo(HitObject.PositionBindable); } - public float Progress => Math.Clamp(Disc.RotationAbsolute / 360 / Spinner.SpinsRequired, 0, 1); + /// + /// The completion progress of this spinner from 0..1 (clamped). + /// + public float Progress + { + get + { + if (Spinner.SpinsRequired == 0) + // some spinners are so short they can't require an integer spin count. + // these become implicitly hit. + return 1; + + return Math.Clamp(RotationTracker.CumulativeRotation / 360 / Spinner.SpinsRequired, 0, 1); + } + } protected override void CheckForResult(bool userTriggered, double timeOffset) { if (Time.Current < HitObject.StartTime) return; - if (Progress >= 1 && !Disc.Complete) - { - Disc.Complete = true; - - const float duration = 200; - - Disc.FadeAccent(completeColour, duration); - - Background.FadeAccent(completeColour, duration); - Background.FadeOut(duration); - - circle.FadeColour(completeColour, duration); - glow.FadeColour(completeColour, duration); - } + RotationTracker.Complete.Value = Progress >= 1; if (userTriggered || Time.Current < Spinner.EndTime) return; + // Trigger a miss result for remaining ticks to avoid infinite gameplay. + foreach (var tick in ticks.Where(t => !t.IsHit)) + tick.TriggerResult(false); + ApplyResult(r => { if (Progress >= 1) @@ -177,59 +217,55 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void Update() { base.Update(); + if (HandleUserInput) - Disc.Tracking = OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false; + RotationTracker.Tracking = !Result.HasResult && (OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false); + + if (spinningSample != null) + // todo: implement SpinnerFrequencyModulate + spinningSample.Frequency.Value = 0.5f + Progress; } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - if (!SpmCounter.IsPresent && Disc.Tracking) + if (!SpmCounter.IsPresent && RotationTracker.Tracking) SpmCounter.FadeIn(HitObject.TimeFadeIn); + SpmCounter.SetRotation(RotationTracker.CumulativeRotation); - circle.Rotation = Disc.Rotation; - Ticks.Rotation = Disc.Rotation; - SpmCounter.SetRotation(Disc.RotationAbsolute); - - float relativeCircleScale = Spinner.Scale * circle.DrawHeight / mainContainer.DrawHeight; - Disc.ScaleTo(relativeCircleScale + (1 - relativeCircleScale) * Progress, 200, Easing.OutQuint); - - symbol.RotateTo(Disc.Rotation / 2, 500, Easing.OutQuint); + updateBonusScore(); } - protected override void UpdateInitialTransforms() + private int wholeSpins; + + private void updateBonusScore() { - base.UpdateInitialTransforms(); + if (ticks.Count == 0) + return; - circleContainer.ScaleTo(Spinner.Scale * 0.3f); - circleContainer.ScaleTo(Spinner.Scale, HitObject.TimePreempt / 1.4f, Easing.OutQuint); + int spins = (int)(RotationTracker.CumulativeRotation / 360); - Disc.RotateTo(-720); - symbol.RotateTo(-720); - - mainContainer - .ScaleTo(0) - .ScaleTo(Spinner.Scale * circle.DrawHeight / DrawHeight * 1.4f, HitObject.TimePreempt - 150, Easing.OutQuint) - .Then() - .ScaleTo(1, 500, Easing.OutQuint); - } - - protected override void UpdateStateTransforms(ArmedState state) - { - base.UpdateStateTransforms(state); - - var sequence = this.Delay(Spinner.Duration).FadeOut(160); - - switch (state) + if (spins < wholeSpins) { - case ArmedState.Hit: - sequence.ScaleTo(Scale * 1.2f, 320, Easing.Out); - break; + // rewinding, silently handle + wholeSpins = spins; + return; + } - case ArmedState.Miss: - sequence.ScaleTo(Scale * 0.8f, 320, Easing.In); - break; + while (wholeSpins != spins) + { + var tick = ticks.FirstOrDefault(t => !t.IsHit); + + // tick may be null if we've hit the spin limit. + if (tick != null) + { + tick.TriggerResult(true); + if (tick is DrawableSpinnerBonusTick) + bonusDisplay.SetBonusCount(spins - Spinner.SpinsRequired); + } + + wholeSpins++; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs new file mode 100644 index 0000000000..2e1c07c4c6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class DrawableSpinnerBonusTick : DrawableSpinnerTick + { + public DrawableSpinnerBonusTick(SpinnerBonusTick spinnerTick) + : base(spinnerTick) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs new file mode 100644 index 0000000000..c390b673be --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class DrawableSpinnerTick : DrawableOsuHitObject + { + public override bool DisplayResult => false; + + public DrawableSpinnerTick(SpinnerTick spinnerTick) + : base(spinnerTick) + { + } + + /// + /// Apply a judgement result. + /// + /// Whether this tick was reached. + internal void TriggerResult(bool hit) => ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : HitResult.Miss); + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs new file mode 100644 index 0000000000..dfb692eba9 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs @@ -0,0 +1,189 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public class DefaultSpinnerDisc : CompositeDrawable + { + private DrawableSpinner drawableSpinner; + + private Spinner spinner; + + private const float idle_alpha = 0.2f; + private const float tracking_alpha = 0.4f; + + private Color4 normalColour; + private Color4 completeColour; + + private SpinnerTicks ticks; + + private int wholeRotationCount; + + private SpinnerFill fill; + private Container mainContainer; + private SpinnerCentreLayer centre; + private SpinnerBackgroundLayer background; + + public DefaultSpinnerDisc() + { + RelativeSizeAxes = Axes.Both; + + // we are slightly bigger than our parent, to clip the top and bottom of the circle + // this should probably be revisited when scaled spinners are a thing. + Scale = new Vector2(1.3f); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, DrawableHitObject drawableHitObject) + { + drawableSpinner = (DrawableSpinner)drawableHitObject; + spinner = (Spinner)drawableSpinner.HitObject; + + normalColour = colours.BlueDark; + completeColour = colours.YellowLight; + + InternalChildren = new Drawable[] + { + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + background = new SpinnerBackgroundLayer(), + fill = new SpinnerFill + { + Alpha = idle_alpha, + AccentColour = normalColour + }, + ticks = new SpinnerTicks + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AccentColour = normalColour + }, + } + }, + centre = new SpinnerCentreLayer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableSpinner.RotationTracker.Complete.BindValueChanged(complete => updateComplete(complete.NewValue, 200)); + drawableSpinner.State.BindValueChanged(updateStateTransforms, true); + } + + protected override void Update() + { + base.Update(); + + if (drawableSpinner.RotationTracker.Complete.Value) + { + if (checkNewRotationCount) + { + fill.FinishTransforms(false, nameof(Alpha)); + fill + .FadeTo(tracking_alpha + 0.2f, 60, Easing.OutExpo) + .Then() + .FadeTo(tracking_alpha, 250, Easing.OutQuint); + } + } + else + { + fill.Alpha = (float)Interpolation.Damp(fill.Alpha, drawableSpinner.RotationTracker.Tracking ? tracking_alpha : idle_alpha, 0.98f, (float)Math.Abs(Clock.ElapsedFrameTime)); + } + + const float initial_scale = 0.2f; + float targetScale = initial_scale + (1 - initial_scale) * drawableSpinner.Progress; + + fill.Scale = new Vector2((float)Interpolation.Lerp(fill.Scale.X, targetScale, Math.Clamp(Math.Abs(Time.Elapsed) / 100, 0, 1))); + mainContainer.Rotation = drawableSpinner.RotationTracker.Rotation; + } + + private void updateStateTransforms(ValueChangedEvent state) + { + centre.ScaleTo(0); + mainContainer.ScaleTo(0); + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt / 2, true)) + { + // constant ambient rotation to give the spinner "spinning" character. + this.RotateTo((float)(25 * spinner.Duration / 2000), spinner.TimePreempt + spinner.Duration); + + centre.ScaleTo(0.3f, spinner.TimePreempt / 4, Easing.OutQuint); + mainContainer.ScaleTo(0.2f, spinner.TimePreempt / 4, Easing.OutQuint); + + using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + { + centre.ScaleTo(0.5f, spinner.TimePreempt / 2, Easing.OutQuint); + mainContainer.ScaleTo(1, spinner.TimePreempt / 2, Easing.OutQuint); + } + } + + // transforms we have from completing the spinner will be rolled back, so reapply immediately. + updateComplete(state.NewValue == ArmedState.Hit, 0); + + using (BeginDelayedSequence(spinner.Duration, true)) + { + switch (state.NewValue) + { + case ArmedState.Hit: + this.ScaleTo(Scale * 1.2f, 320, Easing.Out); + this.RotateTo(mainContainer.Rotation + 180, 320); + break; + + case ArmedState.Miss: + this.ScaleTo(Scale * 0.8f, 320, Easing.In); + break; + } + } + } + + private void updateComplete(bool complete, double duration) + { + var colour = complete ? completeColour : normalColour; + + ticks.FadeAccent(colour.Darken(1), duration); + fill.FadeAccent(colour.Darken(1), duration); + + background.FadeAccent(colour, duration); + centre.FadeAccent(colour, duration); + } + + private bool checkNewRotationCount + { + get + { + int rotations = (int)(drawableSpinner.RotationTracker.CumulativeRotation / 360); + + if (wholeRotationCount == rotations) return false; + + wholeRotationCount = rotations; + return true; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs index 395c76a233..07dc6021c9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces private readonly Slider slider; private readonly Drawable followCircle; private readonly DrawableSlider drawableSlider; - private readonly CircularContainer ball; + private readonly Drawable ball; public SliderBall(Slider slider, DrawableSlider drawableSlider = null) { @@ -54,19 +54,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Alpha = 0, Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()), }, - ball = new CircularContainer + ball = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBall), _ => new DefaultSliderBall()) { - Masking = true, - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, Anchor = Anchor.Centre, - Alpha = 1, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBall), _ => new DefaultSliderBall()), - } - } + Origin = Anchor.Centre, + }, }; } @@ -187,12 +179,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces return; Position = newPos; - Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); + ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); lastPosition = newPos; } - private class FollowCircleContainer : Container + private class FollowCircleContainer : CircularContainer { public override bool HandlePositionalInput => true; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBonusDisplay.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBonusDisplay.cs new file mode 100644 index 0000000000..b499d7a92b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBonusDisplay.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + /// + /// Shows incremental bonus score achieved for a spinner. + /// + public class SpinnerBonusDisplay : CompositeDrawable + { + private readonly OsuSpriteText bonusCounter; + + public SpinnerBonusDisplay() + { + AutoSizeAxes = Axes.Both; + + InternalChild = bonusCounter = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Numeric.With(size: 24), + Alpha = 0, + }; + } + + private int displayedCount; + + public void SetBonusCount(int count) + { + if (displayedCount == count) + return; + + displayedCount = count; + bonusCounter.Text = $"{SpinnerBonusTick.SCORE_PER_TICK * count}"; + bonusCounter.FadeOutFromOne(1500); + bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerFill.cs similarity index 85% rename from osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs rename to osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerFill.cs index 77228e28af..043bc5618c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerFill.cs @@ -1,18 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SpinnerBackground : CircularContainer, IHasAccentColour + public class SpinnerFill : CircularContainer, IHasAccentColour { - protected Box Disc; + public readonly Box Disc; public Color4 AccentColour { @@ -31,11 +31,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } } - public SpinnerBackground() + public SpinnerFill() { RelativeSizeAxes = Axes.Both; Masking = true; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Children = new Drawable[] { Disc = new Box diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs similarity index 54% rename from osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs rename to osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs index d4ef039b79..0cc6c842f4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs @@ -2,76 +2,51 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; using osu.Framework.Utils; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SpinnerDisc : CircularContainer, IHasAccentColour + public class SpinnerRotationTracker : CircularContainer { private readonly Spinner spinner; - public Color4 AccentColour - { - get => background.AccentColour; - set => background.AccentColour = value; - } - - private readonly SpinnerBackground background; - - private const float idle_alpha = 0.2f; - private const float tracking_alpha = 0.4f; - public override bool IsPresent => true; // handle input when hidden - public SpinnerDisc(Spinner s) + public SpinnerRotationTracker(Spinner s) { spinner = s; RelativeSizeAxes = Axes.Both; - - Children = new Drawable[] - { - background = new SpinnerBackground { Alpha = idle_alpha }, - }; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - private bool tracking; + public bool Tracking { get; set; } - public bool Tracking - { - get => tracking; - set - { - if (value == tracking) return; + public readonly BindableBool Complete = new BindableBool(); - tracking = value; + /// + /// The total rotation performed on the spinner disc, disregarding the spin direction. + /// + /// + /// 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). + /// + /// + /// 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 ). + /// + public float CumulativeRotation { get; private set; } - background.FadeTo(tracking ? tracking_alpha : idle_alpha, 100); - } - } - - private bool complete; - - public bool Complete - { - get => complete; - set - { - if (value == complete) return; - - complete = value; - - updateCompleteTick(); - } - } + /// + /// Whether the spinning is spinning at a reasonable speed to be considered visually spinning. + /// + public readonly BindableBool IsSpinning = new BindableBool(); /// /// Whether currently in the correct time range to allow spinning. @@ -88,10 +63,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces private float lastAngle; private float currentRotation; - public float RotationAbsolute; - private int completeTick; - - private bool updateCompleteTick() => completeTick != (completeTick = (int)(RotationAbsolute / 360)); private bool rotationTransferred; @@ -102,21 +73,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces var delta = thisAngle - lastAngle; - if (tracking) - Rotate(delta); + if (Tracking) + AddRotation(delta); lastAngle = thisAngle; - if (Complete && updateCompleteTick()) - { - background.FinishTransforms(false, nameof(Alpha)); - background - .FadeTo(tracking_alpha + 0.2f, 60, Easing.OutExpo) - .Then() - .FadeTo(tracking_alpha, 250, Easing.OutQuint); - } + IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation / 2 - Rotation) > 5f; - Rotation = (float)Interpolation.Lerp(Rotation, currentRotation / 2, Math.Clamp(Math.Abs(Time.Elapsed) / 40, 0, 1)); + Rotation = (float)Interpolation.Damp(Rotation, currentRotation / 2, 0.99, Math.Abs(Time.Elapsed)); } /// @@ -126,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces /// Will be a no-op if not a valid time to spin. /// /// The delta angle. - public void Rotate(float angle) + public void AddRotation(float angle) { if (!isSpinnableTime) return; @@ -149,7 +113,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } currentRotation += angle; - RotationAbsolute += Math.Abs(angle) * Math.Sign(Clock.ElapsedFrameTime); + CumulativeRotation += Math.Abs(angle) * Math.Sign(Clock.ElapsedFrameTime); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs index 676cefb236..ba7e8eae6f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,10 +10,11 @@ using osu.Framework.Graphics.Effects; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SpinnerTicks : Container + public class SpinnerTicks : Container, IHasAccentColour { public SpinnerTicks() { @@ -20,28 +22,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; RelativeSizeAxes = Axes.Both; - const float count = 18; + const float count = 8; for (float i = 0; i < count; i++) { Add(new Container { - Colour = Color4.Black, Alpha = 0.4f, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 10, - Colour = Color4.Gray.Opacity(0.2f), - }, + Blending = BlendingParameters.Additive, RelativePositionAxes = Axes.Both, Masking = true, CornerRadius = 5, Size = new Vector2(60, 10), Origin = Anchor.Centre, Position = new Vector2( - 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.86f, - 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.86f + 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.83f, + 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.83f ), Rotation = -i / count * 360 + 90, Children = new[] @@ -54,5 +50,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }); } } + + public Color4 AccentColour + { + get => Colour; + set + { + Colour = value; + + foreach (var c in Children.OfType()) + { + c.EdgeEffect = + new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 20, + Colour = value.Opacity(0.8f), + }; + } + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerBackgroundLayer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerBackgroundLayer.cs new file mode 100644 index 0000000000..3cd2454706 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerBackgroundLayer.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class SpinnerBackgroundLayer : SpinnerFill + { + [BackgroundDependencyLoader] + private void load(OsuColour colours, DrawableHitObject drawableHitObject) + { + Disc.Alpha = 0; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerCentreLayer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerCentreLayer.cs new file mode 100644 index 0000000000..b62ce822f0 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerCentreLayer.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class SpinnerCentreLayer : CompositeDrawable, IHasAccentColour + { + private DrawableSpinner spinner; + + private CirclePiece circle; + private GlowPiece glow; + private SpriteIcon symbol; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + spinner = (DrawableSpinner)drawableHitObject; + + InternalChildren = new Drawable[] + { + glow = new GlowPiece(), + circle = new CirclePiece + { + Position = Vector2.Zero, + Anchor = Anchor.Centre, + }, + new RingPiece(), + symbol = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(48), + Icon = FontAwesome.Solid.Asterisk, + Shadow = false, + }, + }; + } + + protected override void Update() + { + base.Update(); + symbol.Rotation = (float)Interpolation.Lerp(symbol.Rotation, spinner.RotationTracker.Rotation / 2, Math.Clamp(Math.Abs(Time.Elapsed) / 40, 0, 1)); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + circle.Colour = accentColour; + glow.Colour = accentColour; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index c11e20c9e7..1e54b576f1 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -4,6 +4,7 @@ using osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects @@ -24,6 +25,13 @@ namespace osu.Game.Rulesets.Osu.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; - public override Judgement CreateJudgement() => new SliderRepeat.SliderRepeatJudgement(); + public override Judgement CreateJudgement() => new SliderTailJudgement(); + + public class SliderTailJudgement : OsuJudgement + { + protected override int NumericResultFor(HitResult result) => 0; + + public override bool AffectsCombo => false; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 418375c090..1658a4e7c2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -1,11 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; @@ -26,14 +25,43 @@ namespace osu.Game.Rulesets.Osu.Objects /// public int SpinsRequired { get; protected set; } = 1; + /// + /// Number of spins available to give bonus, beyond . + /// + public int MaximumBonusSpins { get; protected set; } = 1; + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - SpinsRequired = (int)(Duration / 1000 * BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 3, 5, 7.5)); - // spinning doesn't match 1:1 with stable, so let's fudge them easier for the time being. - SpinsRequired = (int)Math.Max(1, SpinsRequired * 0.6); + const double stable_matching_fudge = 0.6; + + // close to 477rpm + const double maximum_rotations_per_second = 8; + + double secondsDuration = Duration / 1000; + + double minimumRotationsPerSecond = stable_matching_fudge * BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 3, 5, 7.5); + + SpinsRequired = (int)(secondsDuration * minimumRotationsPerSecond); + MaximumBonusSpins = (int)((maximum_rotations_per_second - minimumRotationsPerSecond) * secondsDuration); + } + + protected override void CreateNestedHitObjects() + { + base.CreateNestedHitObjects(); + + int totalSpins = MaximumBonusSpins + SpinsRequired; + + for (int i = 0; i < totalSpins; i++) + { + double startTime = StartTime + (float)(i + 1) / totalSpins * Duration; + + AddNested(i < SpinsRequired + ? new SpinnerTick { StartTime = startTime } + : new SpinnerBonusTick { StartTime = startTime }); + } } public override Judgement CreateJudgement() => new OsuJudgement(); diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs new file mode 100644 index 0000000000..9c4b6f774f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Audio; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects +{ + public class SpinnerBonusTick : SpinnerTick + { + public new const int SCORE_PER_TICK = 50; + + public SpinnerBonusTick() + { + Samples.Add(new HitSampleInfo { Name = "spinnerbonus" }); + } + + public override Judgement CreateJudgement() => new OsuSpinnerBonusTickJudgement(); + + public class OsuSpinnerBonusTickJudgement : OsuSpinnerTickJudgement + { + protected override int NumericResultFor(HitResult result) => SCORE_PER_TICK; + + protected override double HealthIncreaseFor(HitResult result) => base.HealthIncreaseFor(result) * 2; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs new file mode 100644 index 0000000000..de3ae27e55 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects +{ + public class SpinnerTick : OsuHitObject + { + public const int SCORE_PER_TICK = 10; + + public override Judgement CreateJudgement() => new OsuSpinnerTickJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public class OsuSpinnerTickJudgement : OsuJudgement + { + public override bool AffectsCombo => false; + + protected override int NumericResultFor(HitResult result) => SCORE_PER_TICK; + + protected override double HealthIncreaseFor(HitResult result) => result == MaxResult ? 0.6 * base.HealthIncreaseFor(result) : 0; + } + } +} diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 689a7b35ea..eaa5d8937a 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -29,9 +29,15 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Skinning; using System; +using System.Linq; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Osu { + [ExcludeFromDynamicCompile] public class OsuRuleset : Ruleset, ILegacyRuleset { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuRuleset(this, beatmap, mods); @@ -186,5 +192,31 @@ namespace osu.Game.Rulesets.Osu public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame(); public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo); + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList()) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + }, + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Accuracy Heatmap", new AccuracyHeatmap(score, playableBeatmap) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + } + }; } } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index b2cdc8ccbf..5468764692 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -17,5 +17,6 @@ namespace osu.Game.Rulesets.Osu SliderFollowCircle, SliderBall, SliderBody, + SpinnerBody } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs index 9ab358ee12..3356a0fbe0 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Replays /// protected static readonly Vector2 SPINNER_CENTRE = OsuPlayfield.BASE_SIZE / 2; - protected const float SPIN_RADIUS = 50; + public const float SPIN_RADIUS = 50; /// /// The time in ms between each ReplayFrame. diff --git a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs index b42e9ac187..cf48dc053f 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs @@ -36,19 +36,10 @@ namespace osu.Game.Rulesets.Osu.Replays } } - public override List GetPendingInputs() + public override void CollectPendingInputs(List inputs) { - return new List - { - new MousePositionAbsoluteInput - { - Position = GamefieldToScreenSpace(Position ?? Vector2.Zero) - }, - new ReplayState - { - PressedActions = CurrentFrame?.Actions ?? new List() - } - }; + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); } } } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index 79a6ea7e92..86ec76e373 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -4,13 +4,27 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { public class OsuScoreProcessor : ScoreProcessor { - protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new OsuJudgementResult(hitObject, judgement); + protected override HitEvent CreateHitEvent(JudgementResult result) + => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); + + protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) + { + switch (hitObject) + { + case HitCircle _: + return new OsuHitCircleJudgementResult(hitObject, judgement); + + default: + return new OsuJudgementResult(hitObject, judgement); + } + } public override HitWindows CreateHitWindows() => new OsuHitWindows(); } diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs index e7486ef9b0..0ab3e8825b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; +using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Rulesets.Osu.Skinning { @@ -28,53 +29,67 @@ namespace osu.Game.Rulesets.Osu.Skinning Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); } + private Container circleSprites; + private Sprite hitCircleSprite; + private Sprite hitCircleOverlay; + + private SkinnableSpriteText hitCircleText; + private readonly IBindable state = new Bindable(); private readonly Bindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); + [Resolved] + private ISkinSource skin { get; set; } + [BackgroundDependencyLoader] - private void load(DrawableHitObject drawableObject, ISkinSource skin) + private void load(DrawableHitObject drawableObject) { OsuHitObject osuObject = (OsuHitObject)drawableObject.HitObject; - Sprite hitCircleSprite; - SkinnableSpriteText hitCircleText; - InternalChildren = new Drawable[] { - hitCircleSprite = new Sprite + circleSprites = new Container { - Texture = getTextureWithFallback(string.Empty), - Colour = drawableObject.AccentColour.Value, Anchor = Anchor.Centre, Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + hitCircleSprite = new Sprite + { + Texture = getTextureWithFallback(string.Empty), + Colour = drawableObject.AccentColour.Value, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + hitCircleOverlay = new Sprite + { + Texture = getTextureWithFallback("overlay"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } }, hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText { Font = OsuFont.Numeric.With(size: 40), UseFullGlyphHeight = false, - }, confineMode: ConfineMode.NoScaling), - new Sprite + }, confineMode: ConfineMode.NoScaling) { - Texture = getTextureWithFallback("overlay"), Anchor = Anchor.Centre, Origin = Anchor.Centre, - } + }, }; bool overlayAboveNumber = skin.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber)?.Value ?? true; - if (!overlayAboveNumber) - ChangeInternalChildDepth(hitCircleText, -float.MaxValue); + if (overlayAboveNumber) + AddInternal(hitCircleOverlay.CreateProxy()); state.BindTo(drawableObject.State); - state.BindValueChanged(updateState, true); - accentColour.BindTo(drawableObject.AccentColour); - accentColour.BindValueChanged(colour => hitCircleSprite.Colour = colour.NewValue, true); - indexInCurrentCombo.BindTo(osuObject.IndexInCurrentComboBindable); - indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); Texture getTextureWithFallback(string name) { @@ -87,6 +102,15 @@ namespace osu.Game.Rulesets.Osu.Skinning } } + protected override void LoadComplete() + { + base.LoadComplete(); + + state.BindValueChanged(updateState, true); + accentColour.BindValueChanged(colour => hitCircleSprite.Colour = colour.NewValue, true); + indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); + } + private void updateState(ValueChangedEvent state) { const double legacy_fade_duration = 240; @@ -94,8 +118,21 @@ namespace osu.Game.Rulesets.Osu.Skinning switch (state.NewValue) { case ArmedState.Hit: - this.FadeOut(legacy_fade_duration, Easing.Out); - this.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + circleSprites.FadeOut(legacy_fade_duration, Easing.Out); + circleSprites.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + + var legacyVersion = skin.GetConfig(LegacySetting.Version)?.Value; + + if (legacyVersion >= 2.0m) + // legacy skins of version 2.0 and newer only apply very short fade out to the number piece. + hitCircleText.FadeOut(legacy_fade_duration / 4, Easing.Out); + else + { + // old skins scale and fade it normally along other pieces. + hitCircleText.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleText.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + } + break; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyNewStyleSpinner.cs new file mode 100644 index 0000000000..72bc3ddc9a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyNewStyleSpinner.cs @@ -0,0 +1,99 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + /// + /// Legacy skinned spinner with two main spinning layers, one fixed overlay and one final spinning overlay. + /// No background layer. + /// + public class LegacyNewStyleSpinner : CompositeDrawable + { + private Sprite discBottom; + private Sprite discTop; + private Sprite spinningMiddle; + private Sprite fixedMiddle; + + private DrawableSpinner drawableSpinner; + + private const float final_scale = 0.625f; + + [BackgroundDependencyLoader] + private void load(ISkinSource source, DrawableHitObject drawableObject) + { + drawableSpinner = (DrawableSpinner)drawableObject; + + Scale = new Vector2(final_scale); + + InternalChildren = new Drawable[] + { + discBottom = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-bottom") + }, + discTop = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-top") + }, + fixedMiddle = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-middle") + }, + spinningMiddle = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-middle2") + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeOut(); + drawableSpinner.State.BindValueChanged(updateStateTransforms, true); + } + + private void updateStateTransforms(ValueChangedEvent state) + { + var spinner = (Spinner)drawableSpinner.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt / 2, true)) + this.FadeInFromZero(spinner.TimePreempt / 2); + + fixedMiddle.FadeColour(Color4.White); + using (BeginAbsoluteSequence(spinner.StartTime, true)) + fixedMiddle.FadeColour(Color4.Red, spinner.Duration); + } + + protected override void Update() + { + base.Update(); + spinningMiddle.Rotation = discTop.Rotation = drawableSpinner.RotationTracker.Rotation; + discBottom.Rotation = discTop.Rotation / 3; + + Scale = new Vector2(final_scale * (0.8f + (float)Interpolation.ApplyEasing(Easing.Out, drawableSpinner.Progress) * 0.2f)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyOldStyleSpinner.cs new file mode 100644 index 0000000000..0ae1d8f683 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyOldStyleSpinner.cs @@ -0,0 +1,114 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + /// + /// Legacy skinned spinner with one main spinning layer and a background layer. + /// + public class LegacyOldStyleSpinner : CompositeDrawable + { + private DrawableSpinner drawableSpinner; + private Sprite disc; + private Container metre; + + private const float background_y_offset = 20; + + private const float sprite_scale = 1 / 1.6f; + + [BackgroundDependencyLoader] + private void load(ISkinSource source, DrawableHitObject drawableObject) + { + drawableSpinner = (DrawableSpinner)drawableObject; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Sprite + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Texture = source.GetTexture("spinner-background"), + Y = background_y_offset, + Scale = new Vector2(sprite_scale) + }, + disc = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-circle"), + Scale = new Vector2(sprite_scale) + }, + metre = new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Y = background_y_offset, + Masking = true, + Child = new Sprite + { + Texture = source.GetTexture("spinner-metre"), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + Scale = new Vector2(0.625f) + } + }; + } + + private Vector2 metreFinalSize; + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeOut(); + drawableSpinner.State.BindValueChanged(updateStateTransforms, true); + + metreFinalSize = metre.Size = metre.Child.Size; + } + + private void updateStateTransforms(ValueChangedEvent state) + { + var spinner = drawableSpinner.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt / 2, true)) + this.FadeInFromZero(spinner.TimePreempt / 2); + } + + protected override void Update() + { + base.Update(); + disc.Rotation = drawableSpinner.RotationTracker.Rotation; + metre.Height = getMetreHeight(drawableSpinner.Progress); + } + + private const int total_bars = 10; + + private float getMetreHeight(float progress) + { + progress = Math.Min(99, progress * 100); + + int barCount = (int)progress / 10; + + // todo: add SpinnerNoBlink support + if (RNG.NextBool(((int)progress % 10) / 10f)) + barCount++; + + return (float)barCount / total_bars * metreFinalSize.Y; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs index b4ed75d97c..0f586034d5 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs @@ -15,6 +15,9 @@ namespace osu.Game.Rulesets.Osu.Skinning { private readonly Drawable animationContent; + private Sprite layerNd; + private Sprite layerSpec; + public LegacySliderBall(Drawable animationContent) { this.animationContent = animationContent; @@ -29,18 +32,37 @@ namespace osu.Game.Rulesets.Osu.Skinning InternalChildren = new[] { - new Sprite + layerNd = new Sprite { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Texture = skin.GetTexture("sliderb-nd"), Colour = new Color4(5, 5, 5, 255), }, - animationContent, - new Sprite + animationContent.With(d => { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }), + layerSpec = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Texture = skin.GetTexture("sliderb-spec"), Blending = BlendingParameters.Additive, }, }; } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + //undo rotation on layers which should not be rotated. + float appliedRotation = Parent.Rotation; + + layerNd.Rotation = -appliedRotation; + layerSpec.Rotation = -appliedRotation; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs index 3e5758ca01..81d1d05b66 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Osu.Skinning case OsuSkinComponents.HitCircleText: var font = GetConfig(OsuSkinConfiguration.HitCirclePrefix)?.Value ?? "default"; - var overlap = GetConfig(OsuSkinConfiguration.HitCircleOverlap)?.Value ?? 0; + var overlap = GetConfig(OsuSkinConfiguration.HitCircleOverlap)?.Value ?? -2; return !hasFont(font) ? null @@ -102,6 +102,16 @@ namespace osu.Game.Rulesets.Osu.Skinning Scale = new Vector2(0.8f), Spacing = new Vector2(-overlap, 0) }; + + case OsuSkinComponents.SpinnerBody: + bool hasBackground = Source.GetTexture("spinner-background") != null; + + if (Source.GetTexture("spinner-top") != null && !hasBackground) + return new LegacyNewStyleSpinner(); + else if (hasBackground) + return new LegacyOldStyleSpinner(); + + return null; } return null; diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs new file mode 100644 index 0000000000..20adbc1c02 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -0,0 +1,297 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Statistics +{ + public class AccuracyHeatmap : CompositeDrawable + { + /// + /// Size of the inner circle containing the "hit" points, relative to the size of this . + /// All other points outside of the inner circle are "miss" points. + /// + private const float inner_portion = 0.8f; + + /// + /// Number of rows/columns of points. + /// ~4px per point @ 128x128 size (the contents of the are always square). 1089 total points. + /// + private const int points_per_dimension = 33; + + private const float rotation = 45; + + private BufferedContainer bufferedGrid; + private GridContainer pointGrid; + + private readonly ScoreInfo score; + private readonly IBeatmap playableBeatmap; + + private const float line_thickness = 2; + + /// + /// The highest count of any point currently being displayed. + /// + protected float PeakValue { get; private set; } + + public AccuracyHeatmap(ScoreInfo score, IBeatmap playableBeatmap) + { + this.score = score; + this.playableBeatmap = playableBeatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(inner_portion), + Masking = true, + BorderThickness = line_thickness, + BorderColour = Color4.White, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#202624") + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(1), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Y, + Height = 2, // We're rotating along a diagonal - we don't really care how big this is. + Width = line_thickness / 2, + Rotation = -rotation, + Alpha = 0.3f, + }, + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Y, + Height = 2, // We're rotating along a diagonal - we don't really care how big this is. + Width = line_thickness / 2, // adjust for edgesmoothness + Rotation = rotation + }, + } + }, + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 10, + EdgeSmoothness = new Vector2(1), + Height = line_thickness / 2, // adjust for edgesmoothness + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + EdgeSmoothness = new Vector2(1), + Width = line_thickness / 2, // adjust for edgesmoothness + Height = 10, + } + } + }, + bufferedGrid = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + CacheDrawnFrameBuffer = true, + BackgroundColour = Color4Extensions.FromHex("#202624").Opacity(0), + Child = pointGrid = new GridContainer + { + RelativeSizeAxes = Axes.Both + } + }, + } + }; + + Vector2 centre = new Vector2(points_per_dimension) / 2; + float innerRadius = centre.X * inner_portion; + + Drawable[][] points = new Drawable[points_per_dimension][]; + + for (int r = 0; r < points_per_dimension; r++) + { + points[r] = new Drawable[points_per_dimension]; + + for (int c = 0; c < points_per_dimension; c++) + { + HitPointType pointType = Vector2.Distance(new Vector2(c, r), centre) <= innerRadius + ? HitPointType.Hit + : HitPointType.Miss; + + var point = new HitPoint(pointType, this) + { + Colour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255) + }; + + points[r][c] = point; + } + } + + pointGrid.Content = points; + + if (score.HitEvents == null || score.HitEvents.Count == 0) + return; + + // Todo: This should probably not be done like this. + float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5) / 5) / 2; + + foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) + { + if (e.LastHitObject == null || e.Position == null) + continue; + + AddPoint(((OsuHitObject)e.LastHitObject).StackedEndPosition, ((OsuHitObject)e.HitObject).StackedEndPosition, e.Position.Value, radius); + } + } + + protected void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius) + { + if (pointGrid.Content.Length == 0) + return; + + double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point. + double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point. + double finalAngle = angle2 - angle1; // Angle between start, end, and hit points. + float normalisedDistance = Vector2.Distance(hitPoint, end) / radius; + + // Consider two objects placed horizontally, with the start on the left and the end on the right. + // The above calculated the angle between {end, start}, and the angle between {end, hitPoint}, in the form: + // +pi | 0 + // O --------- O -----> Note: Math.Atan2 has a range (-pi <= theta <= +pi) + // -pi | 0 + // E.g. If the hit point was directly above end, it would have an angle pi/2. + // + // It also calculated the angle separating hitPoint from the line joining {start, end}, that is anti-clockwise in the form: + // 0 | pi + // O --------- O -----> + // 2pi | pi + // + // However keep in mind that cos(0)=1 and cos(2pi)=1, whereas we actually want these values to appear on the left, so the x-coordinate needs to be inverted. + // Likewise sin(pi/2)=1 and sin(3pi/2)=-1, whereas we actually want these values to appear on the bottom/top respectively, so the y-coordinate also needs to be inverted. + // + // We also need to apply the anti-clockwise rotation. + var rotatedAngle = finalAngle - MathUtils.DegreesToRadians(rotation); + var rotatedCoordinate = -1 * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle)); + + Vector2 localCentre = new Vector2(points_per_dimension - 1) / 2; + float localRadius = localCentre.X * inner_portion * normalisedDistance; // The radius inside the inner portion which of the heatmap which the closest point lies. + Vector2 localPoint = localCentre + localRadius * rotatedCoordinate; + + // Find the most relevant hit point. + int r = Math.Clamp((int)Math.Round(localPoint.Y), 0, points_per_dimension - 1); + int c = Math.Clamp((int)Math.Round(localPoint.X), 0, points_per_dimension - 1); + + PeakValue = Math.Max(PeakValue, ((HitPoint)pointGrid.Content[r][c]).Increment()); + + bufferedGrid.ForceRedraw(); + } + + private class HitPoint : Circle + { + private readonly HitPointType pointType; + private readonly AccuracyHeatmap heatmap; + + public override bool IsPresent => count > 0; + + public HitPoint(HitPointType pointType, AccuracyHeatmap heatmap) + { + this.pointType = pointType; + this.heatmap = heatmap; + + RelativeSizeAxes = Axes.Both; + Alpha = 1; + } + + private int count; + + /// + /// Increment the value of this point by one. + /// + /// The value after incrementing. + public int Increment() + { + return ++count; + } + + protected override void Update() + { + base.Update(); + + // the point at which alpha is saturated and we begin to adjust colour lightness. + const float lighten_cutoff = 0.95f; + + // the amount of lightness to attribute regardless of relative value to peak point. + const float non_relative_portion = 0.2f; + + float amount = 0; + + // give some amount of alpha regardless of relative count + amount += non_relative_portion * Math.Min(1, count / 10f); + + // add relative portion + amount += (1 - non_relative_portion) * (count / heatmap.PeakValue); + + // apply easing + amount = (float)Interpolation.ApplyEasing(Easing.OutQuint, Math.Min(1, amount)); + + Debug.Assert(amount <= 1); + + Alpha = Math.Min(amount / lighten_cutoff, 1); + if (pointType == HitPointType.Hit) + Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff)); + } + } + + private enum HitPointType + { + Hit, + Miss + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index 4f3d07f208..eea45c6c80 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -59,10 +59,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { if (!cursorExpand) return; - expandTarget.ScaleTo(released_scale).ScaleTo(pressed_scale, 100, Easing.OutQuad); + expandTarget.ScaleTo(released_scale).ScaleTo(pressed_scale, 400, Easing.OutElasticHalf); } - public void Contract() => expandTarget.ScaleTo(released_scale, 100, Easing.OutQuad); + public void Contract() => expandTarget.ScaleTo(released_scale, 400, Easing.OutQuad); private class DefaultCursor : OsuCursorSprite { @@ -115,24 +115,22 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor }, }, }, - new CircularContainer - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.1f), - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - }, - }, - } - } + }, + }, + new Circle + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.14f), + Colour = new Color4(34, 93, 204, 255), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 8, + Colour = Color4.White, + }, + }, }; } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 28600ef55b..5812e8cf75 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -30,7 +30,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private readonly Drawable cursorTrail; - public Bindable CursorScale = new BindableFloat(1); + public IBindable CursorScale => cursorScale; + + private readonly Bindable cursorScale = new BindableFloat(1); private Bindable userCursorScale; private Bindable autoCursorScale; @@ -68,13 +70,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); autoCursorScale.ValueChanged += _ => calculateScale(); - CursorScale.ValueChanged += e => + CursorScale.BindValueChanged(e => { var newScale = new Vector2(e.NewValue); ActiveCursor.Scale = newScale; cursorTrail.Scale = newScale; - }; + }, true); calculateScale(); } @@ -95,7 +97,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor scale *= GetScaleForCircleSize(beatmap.BeatmapInfo.BaseDifficulty.CircleSize); } - CursorScale.Value = scale; + cursorScale.Value = scale; var newScale = new Vector2(scale); diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index b4d51d11c9..b2299398e1 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.UI protected override PassThroughInputManager CreateInputManager() => new OsuInputManager(Ruleset.RulesetInfo); - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer(); + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { AlignWithStoryboard = true }; protected override ResumeOverlay CreateResumeOverlay() => new OsuResumeOverlay(); diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 4b1a2ce43c..4ef9bbe091 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -1,23 +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 osuTK; +using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Connections; -using osu.Game.Rulesets.UI; -using osu.Game.Rulesets.Judgements; +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 { public class OsuPlayfield : Playfield { - private readonly ApproachCircleProxyContainer approachCircles; + private readonly ProxyContainer approachCircles; + private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; private readonly FollowPointRenderer followPoints; private readonly OrderedHitPolicy hitPolicy; @@ -26,10 +33,16 @@ namespace osu.Game.Rulesets.Osu.UI protected override GameplayCursorContainer CreateCursor() => new OsuCursorContainer(); + private readonly IDictionary> poolDictionary = new Dictionary>(); + public OsuPlayfield() { InternalChildren = new Drawable[] { + spinnerProxies = new ProxyContainer + { + RelativeSizeAxes = Axes.Both + }, followPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both, @@ -46,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.UI { Child = HitObjectContainer, }, - approachCircles = new ApproachCircleProxyContainer + approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both, Depth = -1, @@ -54,6 +67,13 @@ namespace osu.Game.Rulesets.Osu.UI }; hitPolicy = new OrderedHitPolicy(HitObjectContainer); + + 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)); + + AddRangeInternal(poolDictionary.Values); } public override void Add(DrawableHitObject h) @@ -61,6 +81,9 @@ namespace osu.Game.Rulesets.Osu.UI h.OnNewResult += onNewResult; h.OnLoadComplete += d => { + if (d is DrawableSpinner) + spinnerProxies.Add(d.CreateProxy()); + if (d is IDrawableHitObjectWithProxiedApproach c) approachCircles.Add(c.ProxiedLayer.CreateProxy()); }; @@ -91,21 +114,37 @@ namespace osu.Game.Rulesets.Osu.UI if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; - DrawableOsuJudgement explosion = new DrawableOsuJudgement(result, judgedObject) - { - Origin = Anchor.Centre, - Position = ((OsuHitObject)judgedObject.HitObject).StackedEndPosition, - Scale = new Vector2(((OsuHitObject)judgedObject.HitObject).Scale) - }; + DrawableOsuJudgement explosion = poolDictionary[result.Type].Get(doj => doj.Apply(result, judgedObject)); judgementLayer.Add(explosion); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos); - private class ApproachCircleProxyContainer : LifetimeManagementContainer + private class ProxyContainer : LifetimeManagementContainer { - public void Add(Drawable approachCircleProxy) => AddInternal(approachCircleProxy); + public void Add(Drawable proxy) => AddInternal(proxy); + } + + private class DrawableJudgementPool : DrawablePool + { + private readonly HitResult result; + + public DrawableJudgementPool(HitResult result) + : base(10) + { + this.result = result; + } + + protected override DrawableOsuJudgement CreateNewDrawable() + { + var judgement = base.CreateNewDrawable(); + + // just a placeholder to initialise the correct drawable hierarchy for this pool. + judgement.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null); + + return judgement; + } } } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs index 9c8be868b0..0d1a5a8304 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs @@ -11,10 +11,19 @@ namespace osu.Game.Rulesets.Osu.UI public class OsuPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { protected override Container Content => content; - private readonly Container content; + private readonly ScalingContainer content; private const float playfield_size_adjust = 0.8f; + /// + /// When true, an offset is applied to allow alignment with historical storyboards displayed in the same parent space. + /// This will shift the playfield downwards slightly. + /// + public bool AlignWithStoryboard + { + set => content.PlayfieldShift = value; + } + public OsuPlayfieldAdjustmentContainer() { Anchor = Anchor.Centre; @@ -39,6 +48,8 @@ namespace osu.Game.Rulesets.Osu.UI /// private class ScalingContainer : Container { + internal bool PlayfieldShift { get; set; } + protected override void Update() { base.Update(); @@ -55,6 +66,7 @@ namespace osu.Game.Rulesets.Osu.UI // Scale = 819.2 / 512 // Scale = 1.6 Scale = new Vector2(Parent.ChildSize.X / OsuPlayfield.BASE_SIZE.X); + Position = new Vector2(0, (PlayfieldShift ? 8f : 0f) * Scale.X); // Size = 0.625 Size = Vector2.Divide(Vector2.One, Scale); } diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index abba444c73..ec7751d2b4 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.UI private OsuClickToResumeCursor clickToResumeCursor; private OsuCursorContainer localCursorContainer; - private Bindable localCursorScale; + private IBindable localCursorScale; public override CursorContainer LocalCursor => State.Value == Visibility.Visible ? localCursorContainer : null; diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/SampleLookups/taiko-hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Rulesets.Taiko.Tests/Resources/SampleLookups/taiko-hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..f9755782c2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Resources/SampleLookups/taiko-hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 1 + +[TimingPoints] +0,300,4,1,2,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonclear.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonclear.png new file mode 100644 index 0000000000..c5bcdbd3fc Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonclear.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonfail.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonfail.png new file mode 100644 index 0000000000..39cf737285 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonfail.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonidle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonidle.png new file mode 100644 index 0000000000..4c3b2bfec9 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonidle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonkiai.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonkiai.png new file mode 100644 index 0000000000..7de00b5390 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonkiai.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index d200c44a02..47d8a5c012 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -36,6 +37,10 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning private TaikoScoreProcessor scoreProcessor; private IEnumerable mascots => this.ChildrenOfType(); + + private IEnumerable animatedMascots => + mascots.Where(mascot => mascot.ChildrenOfType().All(animation => animation.FrameCount > 0)); + private IEnumerable playfields => this.ChildrenOfType(); [SetUp] @@ -72,11 +77,11 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning AddStep("set clear state", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear)); AddStep("miss", () => mascots.ForEach(mascot => mascot.LastResult.Value = new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss })); - AddAssert("skins with animations remain in clear state", () => someMascotsIn(TaikoMascotAnimationState.Clear)); + AddAssert("skins with animations remain in clear state", () => animatedMascotsIn(TaikoMascotAnimationState.Clear)); AddUntilStep("state reverts to fail", () => allMascotsIn(TaikoMascotAnimationState.Fail)); AddStep("set clear state again", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear)); - AddAssert("skins with animations change to clear", () => someMascotsIn(TaikoMascotAnimationState.Clear)); + AddAssert("skins with animations change to clear", () => animatedMascotsIn(TaikoMascotAnimationState.Clear)); } [Test] @@ -111,7 +116,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail); - assertStateAfterResult(new JudgementResult(new DrumRoll(), new TaikoDrumRollJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Fail); + assertStateAfterResult(new JudgementResult(new DrumRoll(), new TaikoDrumRollJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Idle); } @@ -186,10 +191,18 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning private void assertStateAfterResult(JudgementResult judgementResult, TaikoMascotAnimationState expectedState) { - AddStep($"{judgementResult.Type.ToString().ToLower()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", - () => applyNewResult(judgementResult)); + TaikoMascotAnimationState[] mascotStates = null; - AddAssert($"state is {expectedState.ToString().ToLower()}", () => allMascotsIn(expectedState)); + AddStep($"{judgementResult.Type.ToString().ToLower()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", + () => + { + applyNewResult(judgementResult); + // store the states as soon as possible, so that the delay between steps doesn't incorrectly fail the test + // due to not checking if the state changed quickly enough. + Schedule(() => mascotStates = animatedMascots.Select(mascot => mascot.State.Value).ToArray()); + }); + + AddAssert($"state is {expectedState.ToString().ToLower()}", () => mascotStates.All(state => state == expectedState)); } private void applyNewResult(JudgementResult judgementResult) @@ -211,6 +224,6 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning } private bool allMascotsIn(TaikoMascotAnimationState state) => mascots.All(d => d.State.Value == state); - private bool someMascotsIn(TaikoMascotAnimationState state) => mascots.Any(d => d.State.Value == state); + private bool animatedMascotsIn(TaikoMascotAnimationState state) => animatedMascots.Any(d => d.State.Value == state); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs index f7729138ff..d0c57b20c0 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Taiko.Tests [TestCase("basic")] [TestCase("slider-generating-drumroll")] [TestCase("sample-to-type-conversions")] + [TestCase("slider-conversion-v6")] + [TestCase("slider-conversion-v14")] public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs new file mode 100644 index 0000000000..7089ea6619 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Reflection; +using NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneTaikoHitObjectSamples : HitObjectSampleTest + { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + + protected override IResourceStore Resources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); + + [TestCase("taiko-normal-hitnormal")] + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + { + SetupSkins(expectedSample, expectedSample); + + CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expectedSample); + } + + [TestCase("taiko-normal-hitnormal")] + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + { + SetupSkins(string.Empty, expectedSample); + + CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); + + AssertUserLookup(expectedSample); + } + + [TestCase("taiko-normal-hitnormal2")] + [TestCase("normal-hitnormal2")] + public void TestUserSkinLookupIgnoresSampleBank(string unwantedSample) + { + SetupSkins(string.Empty, unwantedSample); + + CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); + + AssertNoLookup(unwantedSample); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs index aaa634648a..0be005e1c4 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Taiko.Tests }; [Test] - public void TestSpinnerDoesNotFail() + public void TestSpinnerDoesFail() { bool judged = false; AddStep("Setup judgements", () => @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Player.ScoreProcessor.NewJudgement += b => judged = true; }); AddUntilStep("swell judged", () => judged); - AddAssert("not failed", () => !Player.HasFailed); + AddAssert("failed", () => Player.HasFailed); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index 28b8476a22..ada7ac5d74 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs new file mode 100644 index 0000000000..7c39c040b1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics.Containers; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Audio +{ + /// + /// Stores samples for the input drum. + /// The lifetime of the samples is adjusted so that they are only alive during the appropriate sample control point. + /// + public class DrumSampleContainer : LifetimeManagementContainer + { + private readonly ControlPointInfo controlPoints; + private readonly Dictionary mappings = new Dictionary(); + + public DrumSampleContainer(ControlPointInfo controlPoints) + { + this.controlPoints = controlPoints; + + IReadOnlyList samplePoints = controlPoints.SamplePoints.Count == 0 ? new[] { controlPoints.SamplePointAt(double.MinValue) } : controlPoints.SamplePoints; + + for (int i = 0; i < samplePoints.Count; i++) + { + var samplePoint = samplePoints[i]; + + var centre = samplePoint.GetSampleInfo(); + var rim = samplePoint.GetSampleInfo(HitSampleInfo.HIT_CLAP); + + var lifetimeStart = i > 0 ? samplePoint.Time : double.MinValue; + var lifetimeEnd = i + 1 < samplePoints.Count ? samplePoints[i + 1].Time : double.MaxValue; + + mappings[samplePoint.Time] = new DrumSample + { + Centre = addSound(centre, lifetimeStart, lifetimeEnd), + Rim = addSound(rim, lifetimeStart, lifetimeEnd) + }; + } + } + + private SkinnableSound addSound(HitSampleInfo hitSampleInfo, double lifetimeStart, double lifetimeEnd) + { + var drawable = new SkinnableSound(hitSampleInfo) + { + LifetimeStart = lifetimeStart, + LifetimeEnd = lifetimeEnd + }; + AddInternal(drawable); + return drawable; + } + + public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time]; + + public class DrumSample + { + public SkinnableSound Centre; + public SkinnableSound Rim; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleMapping.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleMapping.cs deleted file mode 100644 index c31b07344d..0000000000 --- a/osu.Game.Rulesets.Taiko/Audio/DrumSampleMapping.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Taiko.Audio -{ - public class DrumSampleMapping - { - private readonly ControlPointInfo controlPoints; - private readonly Dictionary mappings = new Dictionary(); - - public readonly List Sounds = new List(); - - public DrumSampleMapping(ControlPointInfo controlPoints) - { - this.controlPoints = controlPoints; - - IEnumerable samplePoints = controlPoints.SamplePoints.Count == 0 ? new[] { controlPoints.SamplePointAt(double.MinValue) } : controlPoints.SamplePoints; - - foreach (var s in samplePoints) - { - var centre = s.GetSampleInfo(); - var rim = s.GetSampleInfo(HitSampleInfo.HIT_CLAP); - - mappings[s.Time] = new DrumSample - { - Centre = addSound(centre), - Rim = addSound(rim) - }; - } - } - - private SkinnableSound addSound(HitSampleInfo hitSampleInfo) - { - var drawable = new SkinnableSound(hitSampleInfo); - Sounds.Add(drawable); - return drawable; - } - - public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time]; - - public class DrumSample - { - public SkinnableSound Centre; - public SkinnableSound Rim; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index 78550ed270..2a1aa5d1df 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; namespace osu.Game.Rulesets.Taiko.Beatmaps { @@ -82,37 +83,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps { case IHasDistance distanceData: { - // Number of spans of the object - one for the initial length and for each repeat - int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; - - TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); - DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); - - double speedAdjustment = difficultyPoint.SpeedMultiplier; - double speedAdjustedBeatLength = timingPoint.BeatLength / speedAdjustment; - - // The true distance, accounting for any repeats. This ends up being the drum roll distance later - double distance = distanceData.Distance * spans * LEGACY_VELOCITY_MULTIPLIER; - - // The velocity of the taiko hit object - calculated as the velocity of a drum roll - double taikoVelocity = taiko_base_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / speedAdjustedBeatLength; - // The duration of the taiko hit object - double taikoDuration = distance / taikoVelocity; - - // The velocity of the osu! hit object - calculated as the velocity of a slider - double osuVelocity = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / speedAdjustedBeatLength; - // The duration of the osu! hit object - double osuDuration = distance / osuVelocity; - - // osu-stable always uses the speed-adjusted beatlength to determine the velocities, but - // only uses it for tick rate if beatmap version < 8 - if (beatmap.BeatmapInfo.BeatmapVersion >= 8) - speedAdjustedBeatLength *= speedAdjustment; - - // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat - double tickSpacing = Math.Min(speedAdjustedBeatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, taikoDuration / spans); - - if (!isForCurrentRuleset && tickSpacing > 0 && osuDuration < 2 * speedAdjustedBeatLength) + if (shouldConvertSliderToHits(obj, beatmap, distanceData, out var taikoDuration, out var tickSpacing)) { List> allSamples = obj is IHasPathWithRepeats curveData ? curveData.NodeSamples : new List>(new[] { samples }); @@ -184,6 +155,52 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps } } + private bool shouldConvertSliderToHits(HitObject obj, IBeatmap beatmap, IHasDistance distanceData, out double taikoDuration, out double tickSpacing) + { + // DO NOT CHANGE OR REFACTOR ANYTHING IN HERE WITHOUT TESTING AGAINST _ALL_ BEATMAPS. + // Some of these calculations look redundant, but they are not - extremely small floating point errors are introduced to maintain 1:1 compatibility with stable. + // Rounding cannot be used as an alternative since the error deltas have been observed to be between 1e-2 and 1e-6. + + // The true distance, accounting for any repeats. This ends up being the drum roll distance later + int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; + double distance = distanceData.Distance * spans * LEGACY_VELOCITY_MULTIPLIER; + + TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); + DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); + + double beatLength; +#pragma warning disable 618 + if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) +#pragma warning restore 618 + beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; + else + beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; + + double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + + // The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll. + double taikoVelocity = sliderScoringPointDistance * beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + taikoDuration = distance / taikoVelocity * beatLength; + + if (isForCurrentRuleset) + { + tickSpacing = 0; + return false; + } + + double osuVelocity = taikoVelocity * (1000f / beatLength); + + // osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8 + if (beatmap.BeatmapInfo.BeatmapVersion >= 8) + beatLength = timingPoint.BeatLength; + + // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat + tickSpacing = Math.Min(beatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, taikoDuration / spans); + + return tickSpacing > 0 + && distance / osuVelocity * 1000 < 2 * beatLength; + } + protected override Beatmap CreateBeatmap() => new TaikoBeatmap(); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs index c6fe273b50..2c1885ae1a 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills double addition = 1; // We get an extra addition if we are not a slider or spinner - if (current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000) + if (current.LastObject is Hit && current.BaseObject is Hit && current.BaseObject.StartTime - current.LastObject.StartTime < 1000) { if (hasColourChange(current)) addition += 0.75; diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs index 604daa929f..0d91002f4b 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs @@ -7,8 +7,6 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoDrumRollJudgement : TaikoJudgement { - public override bool AffectsCombo => false; - protected override double HealthIncreaseFor(HitResult result) { // Drum rolls can be ignored with no health penalty diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs index 29be5e0eac..4d61efd3ee 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs @@ -7,8 +7,6 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoSwellJudgement : TaikoJudgement { - public override bool AffectsCombo => false; - protected override double HealthIncreaseFor(HitResult result) { switch (result) diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs index 97337acc45..138e8f9785 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Replays protected override bool IsImportant(TaikoReplayFrame frame) => frame.Actions.Any(); - public override List GetPendingInputs() => new List { new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() } }; + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } } } diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json new file mode 100644 index 0000000000..6a6063cb74 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json @@ -0,0 +1,379 @@ +{ + "Mappings": [{ + "StartTime": 2000, + "Objects": [{ + "StartTime": 2000, + "EndTime": 2000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2173, + "EndTime": 2173, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 4000, + "Objects": [{ + "StartTime": 4000, + "EndTime": 4000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 4173, + "EndTime": 4173, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 6000, + "Objects": [{ + "StartTime": 6000, + "EndTime": 6000, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 6271, + "EndTime": 6271, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 6542, + "EndTime": 6542, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 8000, + "Objects": [{ + "StartTime": 8000, + "EndTime": 8000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8026, + "EndTime": 8026, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8053, + "EndTime": 8053, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8080, + "EndTime": 8080, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8107, + "EndTime": 8107, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8133, + "EndTime": 8133, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8160, + "EndTime": 8160, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8187, + "EndTime": 8187, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8214, + "EndTime": 8214, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8241, + "EndTime": 8241, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8267, + "EndTime": 8267, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8294, + "EndTime": 8294, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8321, + "EndTime": 8321, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8348, + "EndTime": 8348, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8374, + "EndTime": 8374, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8401, + "EndTime": 8401, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8428, + "EndTime": 8428, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8455, + "EndTime": 8455, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8482, + "EndTime": 8482, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8508, + "EndTime": 8508, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8535, + "EndTime": 8535, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8562, + "EndTime": 8562, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8589, + "EndTime": 8589, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8615, + "EndTime": 8615, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8642, + "EndTime": 8642, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8669, + "EndTime": 8669, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8696, + "EndTime": 8696, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8723, + "EndTime": 8723, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8749, + "EndTime": 8749, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8776, + "EndTime": 8776, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8803, + "EndTime": 8803, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8830, + "EndTime": 8830, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8857, + "EndTime": 8857, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu new file mode 100644 index 0000000000..4c8fb1fde6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu @@ -0,0 +1,32 @@ +osu file format v14 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:7 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.2 +SliderMultiplier:2.3 +SliderTickRate:1 + +[TimingPoints] +0,333.333333333333,4,1,0,50,1,0 +2000,-100,4,2,0,80,0,0 + +6000,389.61038961039,4,2,1,60,1,0 + +8000,428.571428571429,4,3,1,65,1,0 +8000,-133.333333333333,4,1,1,45,0,0 + +[HitObjects] +// Should convert. +48,32,2000,6,0,B|168:32,1,120,4|2 +312,68,4000,2,0,B|288:52|256:44|216:52|200:68,1,120,0|8 + +// Should convert. +184,224,6000,2,0,L|336:308,2,160,2|2|0,0:0|0:0|0:0,0:0:0:0: + +// Should convert. +328,36,8000,6,0,L|332:16,32,10.7812504112721,0|0,0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json new file mode 100644 index 0000000000..c3d3c52ebd --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json @@ -0,0 +1,137 @@ +{ + "Mappings": [{ + "StartTime": 0, + "Objects": [{ + "StartTime": 0, + "EndTime": 0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 162, + "EndTime": 162, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 325, + "EndTime": 325, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 487, + "EndTime": 487, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 650, + "EndTime": 650, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 813, + "EndTime": 813, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 975, + "EndTime": 975, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2000, + "Objects": [{ + "StartTime": 2000, + "EndTime": 2000, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2162, + "EndTime": 2162, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2325, + "EndTime": 2325, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2487, + "EndTime": 2487, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2650, + "EndTime": 2650, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2813, + "EndTime": 2813, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2975, + "EndTime": 2975, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu new file mode 100644 index 0000000000..c1e4c3bbd7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu @@ -0,0 +1,20 @@ +osu file format v6 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:3 +CircleSize:4 +OverallDifficulty:1 +SliderMultiplier:1.2 +SliderTickRate:3 + +[TimingPoints] +0,487.884208814441,4,1,0,60,1,0 +2000,-100,4,1,0,65,0,1 + +[HitObjects] +// Should convert. +376,64,0,6,0,B|256:32|136:64,1,240,6|0 +256,120,2000,6,8,C|264:192|336:192,2,120,8|8|6 \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 003d40af56..e29ea87d25 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -7,6 +7,10 @@ namespace osu.Game.Rulesets.Taiko.Scoring { internal class TaikoScoreProcessor : ScoreProcessor { + protected override double DefaultAccuracyPortion => 0.75; + + protected override double DefaultComboPortion => 0.25; + public override HitWindows CreateHitWindows() => new TaikoHitWindows(); } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs index 8531f3cefd..8223e3bc01 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Skinning; @@ -34,13 +35,13 @@ namespace osu.Game.Rulesets.Taiko.Skinning Anchor = Anchor.CentreRight, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, - Texture = skin.GetTexture("taiko-roll-end"), + Texture = skin.GetTexture("taiko-roll-end", WrapMode.ClampToEdge, WrapMode.ClampToEdge), FillMode = FillMode.Fit, }, body = new Sprite { RelativeSizeAxes = Axes.Both, - Texture = skin.GetTexture("taiko-roll-middle"), + Texture = skin.GetTexture("taiko-roll-middle", WrapMode.ClampToEdge, WrapMode.ClampToEdge), }, headCircle = new LegacyCirclePiece { diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs index 81d645e294..b7b55b9ae0 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs @@ -111,7 +111,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning public readonly Sprite Centre; [Resolved] - private DrumSampleMapping sampleMappings { get; set; } + private DrumSampleContainer sampleContainer { get; set; } public LegacyHalfDrum(bool flipped) { @@ -143,7 +143,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning public bool OnPressed(TaikoAction action) { Drawable target = null; - var drumSample = sampleMappings.SampleAt(Time.Current); + var drumSample = sampleContainer.SampleAt(Time.Current); if (action == CentreAction) { diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs index 23d675cfb0..f032c5f485 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs @@ -91,10 +91,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning return null; case TaikoSkinComponents.Mascot: - if (GetTexture("pippidonclear0") != null) - return new DrawableTaikoMascot(); - - return null; + return new DrawableTaikoMascot(); } return Source.GetDrawableComponent(component); diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 4cdd1fbc24..2011842591 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -21,13 +21,18 @@ using osu.Game.Rulesets.Taiko.Difficulty; using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; using System; +using System.Linq; +using osu.Framework.Testing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Skinning; +using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko { + [ExcludeFromDynamicCompile] public class TaikoRuleset : Ruleset, ILegacyRuleset { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); @@ -155,5 +160,20 @@ namespace osu.Game.Rulesets.Taiko public int LegacyID => 1; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame(); + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents.Where(e => e.HitObject is Hit).ToList()) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + } + }; } } diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 06ccd45cb8..5966b24b34 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -25,11 +25,11 @@ namespace osu.Game.Rulesets.Taiko.UI private const float middle_split = 0.025f; [Cached] - private DrumSampleMapping sampleMapping; + private DrumSampleContainer sampleContainer; public InputDrum(ControlPointInfo controlPoints) { - sampleMapping = new DrumSampleMapping(controlPoints); + sampleContainer = new DrumSampleContainer(controlPoints); RelativeSizeAxes = Axes.Both; } @@ -37,39 +37,41 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - Child = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new Container + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(0.9f), - Children = new Drawable[] + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new Container { - new TaikoHalfDrum(false) + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Scale = new Vector2(0.9f), + Children = new Drawable[] { - Name = "Left Half", - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.X, - X = -middle_split / 2, - RimAction = TaikoAction.LeftRim, - CentreAction = TaikoAction.LeftCentre - }, - new TaikoHalfDrum(true) - { - Name = "Right Half", - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.X, - X = middle_split / 2, - RimAction = TaikoAction.RightRim, - CentreAction = TaikoAction.RightCentre + new TaikoHalfDrum(false) + { + Name = "Left Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = -middle_split / 2, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + new TaikoHalfDrum(true) + { + Name = "Right Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = middle_split / 2, + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + } } - } - }); - - AddRangeInternal(sampleMapping.Sounds); + }), + sampleContainer + }; } /// @@ -93,7 +95,7 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly Sprite centreHit; [Resolved] - private DrumSampleMapping sampleMappings { get; set; } + private DrumSampleContainer sampleContainer { get; set; } public TaikoHalfDrum(bool flipped) { @@ -154,7 +156,7 @@ namespace osu.Game.Rulesets.Taiko.UI Drawable target = null; Drawable back = null; - var drumSample = sampleMappings.SampleAt(Time.Current); + var drumSample = sampleContainer.SampleAt(Time.Current); if (action == CentreAction) { diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs index 7de1593ab6..caddc8b122 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Taiko.UI Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Scale = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE), + Size = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE), Masking = true, BorderColour = Color4.White, BorderThickness = border_thickness, @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Taiko.UI Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Scale = new Vector2(TaikoHitObject.DEFAULT_SIZE), + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE), Masking = true, BorderColour = Color4.White, BorderThickness = border_thickness, diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs index 6f25a5f662..9c76aea54c 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs @@ -128,6 +128,13 @@ namespace osu.Game.Rulesets.Taiko.UI } private static Texture getAnimationFrame(ISkin skin, TaikoMascotAnimationState state, int frameIndex) - => skin.GetTexture($"pippidon{state.ToString().ToLower()}{frameIndex}"); + { + var texture = skin.GetTexture($"pippidon{state.ToString().ToLower()}{frameIndex}"); + + if (frameIndex == 0 && texture == null) + texture = skin.GetTexture($"pippidon{state.ToString().ToLower()}"); + + return texture; + } } } diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs index e50b2231bf..460ad1b898 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -157,6 +157,24 @@ namespace osu.Game.Tests.Gameplay assertHealthNotEqualTo(1); } + [Test] + public void TestBonusObjectsExcludedFromDrain() + { + var beatmap = new Beatmap + { + BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, + }; + + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 0 }); + for (double time = 0; time < 5000; time += 100) + beatmap.HitObjects.Add(new JudgeableHitObject(false) { StartTime = time }); + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 5000 }); + + createProcessor(beatmap); + setTime(4900); // Get close to the second combo-affecting object + assertHealthNotEqualTo(0); + } + private Beatmap createBeatmap(double startTime, double endTime, params BreakPeriod[] breaks) { var beatmap = new Beatmap @@ -197,8 +215,25 @@ namespace osu.Game.Tests.Gameplay private class JudgeableHitObject : HitObject { - public override Judgement CreateJudgement() => new Judgement(); + private readonly bool affectsCombo; + + public JudgeableHitObject(bool affectsCombo = true) + { + this.affectsCombo = affectsCombo; + } + + public override Judgement CreateJudgement() => new TestJudgement(affectsCombo); protected override HitWindows CreateHitWindows() => new HitWindows(); + + private class TestJudgement : Judgement + { + public override bool AffectsCombo { get; } + + public TestJudgement(bool affectsCombo) + { + AffectsCombo = affectsCombo; + } + } } } } diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 7a89642e11..2d5e4b911e 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -118,7 +119,7 @@ namespace osu.Game.Tests.Gameplay public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs index 737946e1e0..6b95931b21 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -6,9 +6,9 @@ using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Skinning; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; +using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Tests.Gameplay { @@ -67,9 +67,11 @@ namespace osu.Game.Tests.Gameplay /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin: /// normal-hitnormal2 /// normal-hitnormal + /// hitnormal /// [TestCase("normal-hitnormal2")] [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] public void TestDefaultCustomSampleFromBeatmap(string expectedSample) { SetupSkins(expectedSample, expectedSample); @@ -80,12 +82,13 @@ namespace osu.Game.Tests.Gameplay } /// - /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the user skin when the beatmap does not contain the sample: - /// normal-hitnormal2 + /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the user skin + /// (ignoring the custom sample set index) when the beatmap skin does not contain the sample: /// normal-hitnormal + /// hitnormal /// - [TestCase("normal-hitnormal2")] [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) { SetupSkins(string.Empty, expectedSample); @@ -95,6 +98,23 @@ namespace osu.Game.Tests.Gameplay AssertUserLookup(expectedSample); } + /// + /// Tests that a hitobject which provides a custom sample set of 2 does not retrieve a normal-hitnormal2 sample from the user skin + /// if the beatmap skin does not contain the sample. + /// User skins in stable ignore the custom sample set index when performing lookups. + /// + [Test] + public void TestUserSkinLookupIgnoresSampleBank() + { + const string unwanted_sample = "normal-hitnormal2"; + + SetupSkins(string.Empty, unwanted_sample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample.osu"); + + AssertNoLookup(unwanted_sample); + } + /// /// Tests that a hitobject which provides a sample file retrieves the sample file from the beatmap skin. /// @@ -145,6 +165,7 @@ namespace osu.Game.Tests.Gameplay /// [TestCase("normal-hitnormal2")] [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] public void TestControlPointCustomSampleFromBeatmap(string sampleName) { SetupSkins(sampleName, sampleName); @@ -178,7 +199,7 @@ namespace osu.Game.Tests.Gameplay string[] expectedSamples = { "normal-hitnormal2", - "normal-hitwhistle2" + "normal-hitwhistle" // user skin lookups ignore custom sample set index }; SetupSkins(expectedSamples[0], expectedSamples[1]); @@ -190,7 +211,7 @@ namespace osu.Game.Tests.Gameplay } /// - /// Tests that when a custom sample bank is used, but is disabled, + /// Tests that when a custom sample bank is used, but is disabled, /// only the additional sound will be looked up. /// [Test] @@ -209,7 +230,7 @@ namespace osu.Game.Tests.Gameplay } /// - /// Tests that when a normal sample bank is used and is disabled, + /// Tests that when a normal sample bank is used and is disabled, /// the normal sound will be looked up anyway. /// [Test] @@ -226,6 +247,6 @@ namespace osu.Game.Tests.Gameplay } private void disableLayeredHitSounds() - => AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[GlobalSkinConfiguration.LayeredHitSounds.ToString()] = "0"); + => AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[LegacySetting.LayeredHitSounds.ToString()] = "0"); } } diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index 43c1c77786..6e83c78fda 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -19,24 +19,18 @@ namespace osu.Game.Tests.NonVisual [TestFixture] public class CustomDataDirectoryTest { - [SetUp] - public void SetUp() - { - if (Directory.Exists(customPath)) - Directory.Delete(customPath, true); - } - [Test] public void TestDefaultDirectory() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestDefaultDirectory))) + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestDefaultDirectory))) { try { + string defaultStorageLocation = getDefaultLocationFor(nameof(TestDefaultDirectory)); + var osu = loadOsu(host); var storage = osu.Dependencies.Get(); - string defaultStorageLocation = Path.Combine(RuntimeInfo.StartupDirectory, "headless", nameof(TestDefaultDirectory)); Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); } finally @@ -46,21 +40,14 @@ namespace osu.Game.Tests.NonVisual } } - private string customPath => Path.Combine(RuntimeInfo.StartupDirectory, "custom-path"); - [Test] public void TestCustomDirectory() { - using (var host = new HeadlessGameHost(nameof(TestCustomDirectory))) + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost(nameof(TestCustomDirectory))) { - string defaultStorageLocation = Path.Combine(RuntimeInfo.StartupDirectory, "headless", nameof(TestCustomDirectory)); - - // need access before the game has constructed its own storage yet. - Storage storage = new DesktopStorage(defaultStorageLocation, host); - // manual cleaning so we can prepare a config file. - storage.DeleteDirectory(string.Empty); - - using (var storageConfig = new StorageConfigManager(storage)) + using (var storageConfig = new StorageConfigManager(host.InitialStorage)) storageConfig.Set(StorageConfig.FullPath, customPath); try @@ -68,7 +55,7 @@ namespace osu.Game.Tests.NonVisual var osu = loadOsu(host); // switch to DI'd storage - storage = osu.Dependencies.Get(); + var storage = osu.Dependencies.Get(); Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); } @@ -82,16 +69,11 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestSubDirectoryLookup() { - using (var host = new HeadlessGameHost(nameof(TestSubDirectoryLookup))) + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost(nameof(TestSubDirectoryLookup))) { - string defaultStorageLocation = Path.Combine(RuntimeInfo.StartupDirectory, "headless", nameof(TestSubDirectoryLookup)); - - // need access before the game has constructed its own storage yet. - Storage storage = new DesktopStorage(defaultStorageLocation, host); - // manual cleaning so we can prepare a config file. - storage.DeleteDirectory(string.Empty); - - using (var storageConfig = new StorageConfigManager(storage)) + using (var storageConfig = new StorageConfigManager(host.InitialStorage)) storageConfig.Set(StorageConfig.FullPath, customPath); try @@ -99,7 +81,7 @@ namespace osu.Game.Tests.NonVisual var osu = loadOsu(host); // switch to DI'd storage - storage = osu.Dependencies.Get(); + var storage = osu.Dependencies.Get(); string actualTestFile = Path.Combine(customPath, "rulesets", "test"); @@ -120,14 +102,21 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigration() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigration))) + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigration))) { try { + string defaultStorageLocation = getDefaultLocationFor(nameof(TestMigration)); + var osu = loadOsu(host); var storage = osu.Dependencies.Get(); var osuStorage = storage as MigratableStorage; + // Store the current storage's path. We'll need to refer to this for assertions in the original directory after the migration completes. + string originalDirectory = storage.GetFullPath("."); + // ensure we perform a save host.Dependencies.Get().Save(); @@ -137,8 +126,6 @@ namespace osu.Game.Tests.NonVisual // for testing nested files are not ignored (only top level) host.Storage.GetStorageForDirectory("test-nested").GetStorageForDirectory("cache"); - string defaultStorageLocation = Path.Combine(RuntimeInfo.StartupDirectory, "headless", nameof(TestMigration)); - Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); osu.Migrate(customPath); @@ -146,27 +133,27 @@ namespace osu.Game.Tests.NonVisual Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); // ensure cache was not moved - Assert.That(host.Storage.ExistsDirectory("cache")); + Assert.That(Directory.Exists(Path.Combine(originalDirectory, "cache"))); // ensure nested cache was moved - Assert.That(!host.Storage.ExistsDirectory(Path.Combine("test-nested", "cache"))); + Assert.That(!Directory.Exists(Path.Combine(originalDirectory, "test-nested", "cache"))); Assert.That(storage.ExistsDirectory(Path.Combine("test-nested", "cache"))); Assert.That(osuStorage, Is.Not.Null); foreach (var file in osuStorage.IgnoreFiles) { - Assert.That(host.Storage.Exists(file), Is.True); + Assert.That(File.Exists(Path.Combine(originalDirectory, file))); Assert.That(storage.Exists(file), Is.False); } foreach (var dir in osuStorage.IgnoreDirectories) { - Assert.That(host.Storage.ExistsDirectory(dir), Is.True); + Assert.That(Directory.Exists(Path.Combine(originalDirectory, dir))); Assert.That(storage.ExistsDirectory(dir), Is.False); } - Assert.That(new StreamReader(host.Storage.GetStream("storage.ini")).ReadToEnd().Contains($"FullPath = {customPath}")); + Assert.That(new StreamReader(Path.Combine(originalDirectory, "storage.ini")).ReadToEnd().Contains($"FullPath = {customPath}")); } finally { @@ -178,14 +165,15 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationBetweenTwoTargets() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationBetweenTwoTargets))) + string customPath = prepareCustomPath(); + string customPath2 = prepareCustomPath("-2"); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationBetweenTwoTargets))) { try { var osu = loadOsu(host); - string customPath2 = $"{customPath}-2"; - const string database_filename = "client.db"; Assert.DoesNotThrow(() => osu.Migrate(customPath)); @@ -207,7 +195,9 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationToSameTargetFails() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationToSameTargetFails))) + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationToSameTargetFails))) { try { @@ -226,7 +216,9 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationToNestedTargetFails() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationToNestedTargetFails))) + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationToNestedTargetFails))) { try { @@ -253,7 +245,9 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationToSeeminglyNestedTarget() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationToSeeminglyNestedTarget))) + string customPath = prepareCustomPath(); + + using (HeadlessGameHost host = new CustomTestHeadlessGameHost(nameof(TestMigrationToSeeminglyNestedTarget))) { try { @@ -282,6 +276,7 @@ namespace osu.Game.Tests.NonVisual var osu = new OsuGameBase(); Task.Run(() => host.Run(osu)); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + return osu; } @@ -294,5 +289,39 @@ namespace osu.Game.Tests.NonVisual Assert.IsTrue(task.Wait(timeout), failureMessage); } + + private static string getDefaultLocationFor(string testTypeName) + { + string path = Path.Combine(RuntimeInfo.StartupDirectory, "headless", testTypeName); + + if (Directory.Exists(path)) + Directory.Delete(path, true); + + return path; + } + + private string prepareCustomPath(string suffix = "") + { + string path = Path.Combine(RuntimeInfo.StartupDirectory, $"custom-path{suffix}"); + + if (Directory.Exists(path)) + Directory.Delete(path, true); + + return path; + } + + public class CustomTestHeadlessGameHost : HeadlessGameHost + { + public Storage InitialStorage { get; } + + public CustomTestHeadlessGameHost(string name) + : base(name) + { + string defaultStorageLocation = getDefaultLocationFor(name); + + InitialStorage = new DesktopStorage(defaultStorageLocation, this); + InitialStorage.DeleteDirectory(string.Empty); + } + } } } diff --git a/osu.Game.Tests/Online/TestAPIModSerialization.cs b/osu.Game.Tests/Online/TestAPIModSerialization.cs index d9318aa822..5948582d77 100644 --- a/osu.Game.Tests/Online/TestAPIModSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModSerialization.cs @@ -49,9 +49,32 @@ namespace osu.Game.Tests.Online Assert.That(converted.TestSetting.Value, Is.EqualTo(2)); } + [Test] + public void TestDeserialiseTimeRampMod() + { + // Create the mod with values different from default. + var apiMod = new APIMod(new TestModTimeRamp + { + AdjustPitch = { Value = false }, + InitialRate = { Value = 1.25 }, + FinalRate = { Value = 0.25 } + }); + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset()); + + Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false)); + Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25)); + Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); + } + private class TestRuleset : Ruleset { - public override IEnumerable GetModsFor(ModType type) => new[] { new TestMod() }; + public override IEnumerable GetModsFor(ModType type) => new Mod[] + { + new TestMod(), + new TestModTimeRamp(), + }; public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); @@ -78,5 +101,39 @@ namespace osu.Game.Tests.Online Precision = 0.01, }; } + + private class TestModTimeRamp : ModTimeRamp + { + public override string Name => "Test Mod"; + public override string Acronym => "TMTR"; + public override double ScoreMultiplier => 1; + + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber InitialRate { get; } = new BindableDouble + { + MinValue = 1, + MaxValue = 2, + Default = 1.5, + Value = 1.5, + Precision = 0.01, + }; + + [SettingSource("Final rate", "The speed increase to ramp towards")] + public override BindableNumber FinalRate { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 1, + Default = 0.5, + Value = 0.5, + Precision = 0.01, + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public override BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + } } } diff --git a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs index 1e77d50115..42948c3731 100644 --- a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs +++ b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs @@ -8,7 +8,6 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Tests.Visual; -using osu.Game.Users; namespace osu.Game.Tests.Online { @@ -55,7 +54,7 @@ namespace osu.Game.Tests.Online AddStep("fire request", () => { gotResponse = false; - request = new LeaveChannelRequest(new Channel(), new User()); + request = new LeaveChannelRequest(new Channel()); request.Success += () => gotResponse = true; API.Queue(request); }); @@ -74,7 +73,7 @@ namespace osu.Game.Tests.Online AddStep("fire request", () => { gotResponse = false; - request = new LeaveChannelRequest(new Channel(), new User()); + request = new LeaveChannelRequest(new Channel()); request.Success += () => gotResponse = true; API.Perform(request); }); @@ -93,7 +92,7 @@ namespace osu.Game.Tests.Online AddStep("fire request", () => { gotResponse = false; - request = new LeaveChannelRequest(new Channel(), new User()); + request = new LeaveChannelRequest(new Channel()); request.Success += () => gotResponse = true; API.PerformAsync(request); }); diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index 8deed75a56..ad5b3ec0f6 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -216,7 +217,7 @@ namespace osu.Game.Tests.Skins public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); - public Texture GetTexture(string componentName) => skin.GetTexture(componentName); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); public SampleChannel GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs index 41d1459103..3a19eabe81 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs @@ -175,13 +175,13 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Time = 50", () => Clock.CurrentTime == 50); AddStep("Seek(49.999)", () => Clock.Seek(49.999)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); + AddAssert("Time = 100", () => Clock.CurrentTime == 100); AddStep("Seek(99)", () => Clock.Seek(99)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); AddAssert("Time = 100", () => Clock.CurrentTime == 100); AddStep("Seek(99.999)", () => Clock.Seek(99.999)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); + AddAssert("Time = 100", () => Clock.CurrentTime == 150); AddStep("Seek(174)", () => Clock.Seek(174)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); AddAssert("Time = 175", () => Clock.CurrentTime == 175); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index 2a7f9389d1..09f5ac2224 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; @@ -13,6 +14,7 @@ namespace osu.Game.Tests.Visual.Editing public class TestSceneTimingScreen : EditorClockTestScene { [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; public TestSceneTimingScreen() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 0be949650e..4743317fdd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.Break; using osu.Game.Screens.Ranking; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { @@ -35,6 +36,18 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("rewind", () => Player.GameplayClockContainer.Seek(-80000)); AddUntilStep("key counter reset", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); + double? time = null; + + AddStep("store time", () => time = Player.GameplayClockContainer.GameplayClock.CurrentTime); + + // test seek via keyboard + AddStep("seek with right arrow key", () => press(Key.Right)); + AddAssert("time seeked forward", () => Player.GameplayClockContainer.GameplayClock.CurrentTime > time + 2000); + + AddStep("store time", () => time = Player.GameplayClockContainer.GameplayClock.CurrentTime); + AddStep("seek with left arrow key", () => press(Key.Left)); + AddAssert("time seeked backward", () => Player.GameplayClockContainer.GameplayClock.CurrentTime < time); + seekToBreak(0); seekToBreak(1); @@ -54,5 +67,11 @@ namespace osu.Game.Tests.Visual.Gameplay BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex); } + + private void press(Key key) + { + InputManager.PressKey(key); + InputManager.ReleaseKey(key); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs index a95e806862..1c55595c97 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; @@ -14,6 +15,8 @@ namespace osu.Game.Tests.Visual.Gameplay { private FailingLayer layer; + private readonly Bindable showHealth = new Bindable(); + [Resolved] private OsuConfigManager config { get; set; } @@ -24,8 +27,10 @@ namespace osu.Game.Tests.Visual.Gameplay { Child = layer = new FailingLayer(); layer.BindHealthProcessor(new DrainingHealthProcessor(1)); + layer.ShowHealth.BindTo(showHealth); }); + AddStep("show health", () => showHealth.Value = true); AddStep("enable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); AddUntilStep("layer is visible", () => layer.IsPresent); } @@ -69,5 +74,27 @@ namespace osu.Game.Tests.Visual.Gameplay AddWaitStep("wait for potential fade", 10); AddAssert("layer is still visible", () => layer.IsPresent); } + + [Test] + public void TestLayerVisibilityWithDifferentOptions() + { + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + + AddStep("don't show health", () => showHealth.Value = false); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("don't show health", () => showHealth.Value = false); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("show health", () => showHealth.Value = true); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("show health", () => showHealth.Value = true); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer fade is visible", () => layer.IsPresent); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 1961a224c1..420bf29429 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Rulesets; using osu.Game.Screens.Play; +using osu.Game.Skinning; using osuTK; using osuTK.Input; @@ -221,6 +222,31 @@ namespace osu.Game.Tests.Visual.Gameplay confirmExited(); } + [Test] + public void TestPauseSoundLoop() + { + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + + SkinnableSound getLoop() => Player.ChildrenOfType().FirstOrDefault()?.ChildrenOfType().FirstOrDefault(); + + pauseAndConfirm(); + AddAssert("loop is playing", () => getLoop().IsPlaying); + + resumeAndConfirm(); + AddUntilStep("loop is stopped", () => !getLoop().IsPlaying); + + AddUntilStep("pause again", () => + { + Player.Pause(); + return !Player.GameplayClockContainer.GameplayClock.IsRunning; + }); + + AddAssert("loop is playing", () => getLoop().IsPlaying); + + resumeAndConfirm(); + AddUntilStep("loop is stopped", () => !getLoop().IsPlaying); + } + private void pauseAndConfirm() { pause(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index c7455583e4..bc1c10e59d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -173,19 +173,10 @@ namespace osu.Game.Tests.Visual.Gameplay { } - public override List GetPendingInputs() + public override void CollectPendingInputs(List inputs) { - return new List - { - new MousePositionAbsoluteInput - { - Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) - }, - new ReplayState - { - PressedActions = CurrentFrame?.Actions ?? new List() - } - }; + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs index 7822f07957..c0f99db85d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs @@ -113,19 +113,10 @@ namespace osu.Game.Tests.Visual.Gameplay { } - public override List GetPendingInputs() + public override void CollectPendingInputs(List inputs) { - return new List - { - new MousePositionAbsoluteInput - { - Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) - }, - new ReplayState - { - PressedActions = CurrentFrame?.Actions ?? new List() - } - }; + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index 3b91243fee..bed48f3d86 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Game.Audio; @@ -295,7 +296,7 @@ namespace osu.Game.Tests.Visual.Gameplay } : null; - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); @@ -306,7 +307,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public Drawable GetDrawableComponent(ISkinComponent componentName) => new SecondarySourceBox(); - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); @@ -318,7 +319,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public Drawable GetDrawableComponent(ISkinComponent componentName) => new BaseSourceBox(); - public Texture GetTexture(string componentName) => throw new NotImplementedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs new file mode 100644 index 0000000000..e0a1f947ec --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Screens.Play; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableSound : OsuTestScene + { + [Cached] + private GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + + private SkinnableSound skinnableSound; + + [SetUp] + public void SetUp() => Schedule(() => + { + gameplayClock.IsPaused.Value = false; + + Children = new Drawable[] + { + new Container + { + Clock = gameplayClock, + RelativeSizeAxes = Axes.Both, + Child = skinnableSound = new SkinnableSound(new SampleInfo("normal-sliderslide")) + }, + }; + }); + + [Test] + public void TestStoppedSoundDoesntResumeAfterPause() + { + DrawableSample sample = null; + AddStep("start sample with looping", () => + { + sample = skinnableSound.ChildrenOfType().First(); + + skinnableSound.Looping = true; + skinnableSound.Play(); + }); + + AddUntilStep("wait for sample to start playing", () => sample.Playing); + + AddStep("stop sample", () => skinnableSound.Stop()); + + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); + AddStep("resume gameplay clock", () => gameplayClock.IsPaused.Value = false); + + AddWaitStep("wait a bit", 5); + AddAssert("sample not playing", () => !sample.Playing); + } + + [Test] + public void TestLoopingSoundResumesAfterPause() + { + DrawableSample sample = null; + AddStep("start sample with looping", () => + { + skinnableSound.Looping = true; + skinnableSound.Play(); + sample = skinnableSound.ChildrenOfType().First(); + }); + + AddUntilStep("wait for sample to start playing", () => sample.Playing); + + AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + } + + [Test] + public void TestNonLoopingStopsWithPause() + { + DrawableSample sample = null; + AddStep("start sample", () => + { + skinnableSound.Play(); + sample = skinnableSound.ChildrenOfType().First(); + }); + + AddAssert("sample playing", () => sample.Playing); + + AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("resume gameplay clock", () => gameplayClock.IsPaused.Value = false); + + AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !sample.Playing); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index 2d2f1a1618..f71d13ed35 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -19,10 +19,10 @@ namespace osu.Game.Tests.Visual.Menus [Cached] private OsuLogo logo; + protected OsuScreenStack IntroStack; + protected IntroTestScene() { - OsuScreenStack introStack = null; - Children = new Drawable[] { new Box @@ -45,17 +45,17 @@ namespace osu.Game.Tests.Visual.Menus logo.FinishTransforms(); logo.IsTracking = false; - introStack?.Expire(); + IntroStack?.Expire(); - Add(introStack = new OsuScreenStack + Add(IntroStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both, }); - introStack.Push(CreateScreen()); + IntroStack.Push(CreateScreen()); }); - AddUntilStep("wait for menu", () => introStack.CurrentScreen is MainMenu); + AddUntilStep("wait for menu", () => IntroStack.CurrentScreen is MainMenu); } protected abstract IScreen CreateScreen(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs index 905f17ef0b..8f20e38494 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Audio.Track; using osu.Framework.Screens; using osu.Game.Screens.Menu; @@ -11,5 +12,14 @@ namespace osu.Game.Tests.Visual.Menus public class TestSceneIntroWelcome : IntroTestScene { protected override IScreen CreateScreen() => new IntroWelcome(); + + public TestSceneIntroWelcome() + { + AddUntilStep("wait for load", () => getTrack() != null); + + AddAssert("check if menu music loops", () => getTrack().Looping); + } + + private Track getTrack() => (IntroStack?.CurrentScreen as MainMenu)?.Track; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs new file mode 100644 index 0000000000..8b7e0fd9da --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Screens.Multi; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public abstract class RoomManagerTestScene : MultiplayerTestScene + { + [Cached(Type = typeof(IRoomManager))] + protected TestRoomManager RoomManager { get; } = new TestRoomManager(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("clear rooms", () => RoomManager.Rooms.Clear()); + } + + protected void AddRooms(int count, RulesetInfo ruleset = null) + { + AddStep("add rooms", () => + { + for (int i = 0; i < count; i++) + { + var room = new Room + { + RoomID = { Value = i }, + Name = { Value = $"Room {i}" }, + Host = { Value = new User { Username = "Host" } }, + EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) }, + Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal } + }; + + if (ruleset != null) + { + room.Playlist.Add(new PlaylistItem + { + Ruleset = { Value = ruleset }, + Beatmap = + { + Value = new BeatmapInfo + { + Metadata = new BeatmapMetadata() + } + } + }); + } + + RoomManager.Rooms.Add(room); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs new file mode 100644 index 0000000000..67a53307fc --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestRoomManager : IRoomManager + { + public event Action RoomsUpdated + { + add { } + remove { } + } + + public readonly BindableList Rooms = new BindableList(); + + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + + IBindableList IRoomManager.Rooms => Rooms; + + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) => Rooms.Add(room); + + public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + { + } + + public void PartRoom() + { + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs new file mode 100644 index 0000000000..7c0c2797f5 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeFilterControl : OsuTestScene + { + public TestSceneLoungeFilterControl() + { + Child = new FilterControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs index 8b74eb5f27..cdad37a9ad 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public void Setup() => Schedule(() => { - Room.CopyFrom(new Room()); + Room = new Room(); Child = new RoomInfo { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 83f2297bd2..b1f6ee3e3a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -1,30 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Multi; using osu.Game.Screens.Multi.Lounge.Components; -using osu.Game.Users; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneLoungeRoomsContainer : MultiplayerTestScene + public class TestSceneLoungeRoomsContainer : RoomManagerTestScene { - [Cached(Type = typeof(IRoomManager))] - private TestRoomManager roomManager = new TestRoomManager(); - private RoomsContainer container; [BackgroundDependencyLoader] @@ -39,34 +31,57 @@ namespace osu.Game.Tests.Visual.Multiplayer }; } - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("clear rooms", () => roomManager.Rooms.Clear()); - } - [Test] public void TestBasicListChanges() { - addRooms(3); + AddRooms(3); AddAssert("has 3 rooms", () => container.Rooms.Count == 3); - AddStep("remove first room", () => roomManager.Rooms.Remove(roomManager.Rooms.FirstOrDefault())); + AddStep("remove first room", () => RoomManager.Rooms.Remove(RoomManager.Rooms.FirstOrDefault())); AddAssert("has 2 rooms", () => container.Rooms.Count == 2); AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0)); AddStep("select first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room selected", () => Room == roomManager.Rooms.First()); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); AddStep("join first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room joined", () => roomManager.Rooms.First().Status.Value is JoinedRoomStatus); + AddAssert("first room joined", () => RoomManager.Rooms.First().Status.Value is JoinedRoomStatus); + } + + [Test] + public void TestKeyboardNavigation() + { + AddRooms(3); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + press(Key.Up); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + press(Key.Down); + press(Key.Down); + AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last())); + + press(Key.Enter); + AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus); + } + + private void press(Key down) + { + AddStep($"press {down}", () => + { + InputManager.PressKey(down); + InputManager.ReleaseKey(down); + }); } [Test] public void TestStringFiltering() { - addRooms(4); + AddRooms(4); AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); @@ -82,8 +97,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestRulesetFiltering() { - addRooms(2, new OsuRuleset().RulesetInfo); - addRooms(3, new CatchRuleset().RulesetInfo); + AddRooms(2, new OsuRuleset().RulesetInfo); + AddRooms(3, new CatchRuleset().RulesetInfo); AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); @@ -96,67 +111,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); } - private void addRooms(int count, RulesetInfo ruleset = null) - { - AddStep("add rooms", () => - { - for (int i = 0; i < count; i++) - { - var room = new Room - { - RoomID = { Value = i }, - Name = { Value = $"Room {i}" }, - Host = { Value = new User { Username = "Host" } }, - EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) } - }; - - if (ruleset != null) - { - room.Playlist.Add(new PlaylistItem - { - Ruleset = { Value = ruleset }, - Beatmap = - { - Value = new BeatmapInfo - { - Metadata = new BeatmapMetadata() - } - } - }); - } - - roomManager.Rooms.Add(room); - } - }); - } + private bool checkRoomSelected(Room room) => Room == room; private void joinRequested(Room room) => room.Status.Value = new JoinedRoomStatus(); - private class TestRoomManager : IRoomManager - { - public event Action RoomsUpdated - { - add { } - remove { } - } - - public readonly BindableList Rooms = new BindableList(); - - public Bindable InitialRoomsReceived { get; } = new Bindable(true); - - IBindableList IRoomManager.Rooms => Rooms; - - public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) => Rooms.Add(room); - - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) - { - } - - public void PartRoom() - { - } - } - private class JoinedRoomStatus : RoomStatus { public override string Message => "Joined"; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs new file mode 100644 index 0000000000..68987127d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs @@ -0,0 +1,58 @@ +// 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.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Multi.Lounge; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeSubScreen : RoomManagerTestScene + { + private LoungeSubScreen loungeScreen; + + [BackgroundDependencyLoader] + private void load() + { + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("push screen", () => LoadScreen(loungeScreen = new LoungeSubScreen + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + })); + + AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); + } + + private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + + [Test] + public void TestScrollSelectedIntoView() + { + AddRooms(30); + + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First())); + + AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke()); + + AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First())); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last())); + } + + private bool checkRoomVisible(DrawableRoom room) => + loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad + .Contains(room.ScreenSpaceDrawQuad.Centre); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 24d9f5ab12..01cd26fbe5 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public void Setup() => Schedule(() => { - Room.Playlist.Clear(); + Room = new Room(); Child = new MatchBeatmapDetailArea { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs index 38eb3181bf..e5943105b7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs @@ -14,6 +14,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public TestSceneMatchHeader() { + Room = new Room(); Room.Playlist.Add(new PlaylistItem { Beatmap = diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 7ba1782a28..c24c6c4ba3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Match.Components; using osu.Game.Users; using osuTK; @@ -18,7 +19,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public TestSceneMatchLeaderboard() { - Room.RoomID.Value = 3; + Room = new Room { RoomID = { Value = 3 } }; Add(new MatchLeaderboard { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboardChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboardChatDisplay.cs deleted file mode 100644 index 72bbc11cd0..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboardChatDisplay.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Screens.Multi.Match.Components; -using osuTK; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneMatchLeaderboardChatDisplay : MultiplayerTestScene - { - protected override bool UseOnlineAPI => true; - - public TestSceneMatchLeaderboardChatDisplay() - { - Room.RoomID.Value = 7; - - Add(new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500), - Child = new LeaderboardChatDisplay - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs index 5cff2d7d05..c62479faa0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs @@ -14,6 +14,7 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Multi.Components; @@ -95,7 +96,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public void Setup() => Schedule(() => { - Room.Playlist.Clear(); + Room = new Room(); }); [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs index b687724105..2e22317539 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public void Setup() => Schedule(() => { - Room.CopyFrom(new Room()); + Room = new Room(); }); [SetUpSteps] @@ -58,6 +58,23 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for load", () => match.IsCurrentScreen()); } + [Test] + public void TestLoadSimpleMatch() + { + AddStep("set room properties", () => + { + Room.RoomID.Value = 1; + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.RecentParticipants.Add(Room.Host.Value); + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + } + [Test] public void TestPlaylistItemSelectedOnCreate() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs index 7ea3bba23f..b6bfa7c93a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Components; using osuTK; @@ -12,22 +13,22 @@ namespace osu.Game.Tests.Visual.Multiplayer { protected override bool UseOnlineAPI => true; - public TestSceneOverlinedParticipants() + [SetUp] + public void Setup() => Schedule(() => { - Room.RoomID.Value = 7; - } + Room = new Room { RoomID = { Value = 7 } }; + }); [Test] public void TestHorizontalLayout() { AddStep("create component", () => { - Child = new OverlinedParticipants(Direction.Horizontal) + Child = new ParticipantsDisplay(Direction.Horizontal) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 500, - AutoSizeAxes = Axes.Y, }; }); } @@ -37,7 +38,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create component", () => { - Child = new OverlinedParticipants(Direction.Vertical) + Child = new ParticipantsDisplay(Direction.Vertical) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs index 14b7934dc7..14984988cb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs @@ -4,7 +4,7 @@ using osu.Framework.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Multi.Components; +using osu.Game.Screens.Multi; using osu.Game.Tests.Beatmaps; using osuTK; @@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public TestSceneOverlinedPlaylist() { + Room = new Room { RoomID = { Value = 7 } }; + for (int i = 0; i < 10; i++) { Room.Playlist.Add(new PlaylistItem @@ -26,7 +28,7 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - Add(new OverlinedPlaylist(false) + Add(new DrawableRoomPlaylist(false, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs index 9c4c45f94a..f71c5fc5d2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using NUnit.Framework; using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Components; namespace osu.Game.Tests.Visual.Multiplayer @@ -10,10 +12,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { protected override bool UseOnlineAPI => true; + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room { RoomID = { Value = 7 } }; + }); + public TestSceneParticipantsList() { - Room.RoomID.Value = 7; - Add(new ParticipantsList { RelativeSizeAxes = Axes.Both }); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs index 9fc7c336cb..03fd2b968c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs @@ -3,8 +3,16 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Threading.Tasks; +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; @@ -12,6 +20,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Multi.Ranking; +using osu.Game.Screens.Ranking; using osu.Game.Tests.Beatmaps; using osu.Game.Users; @@ -19,43 +28,134 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneTimeshiftResultsScreen : ScreenTestScene { - private bool roomsReceived; + private const int scores_per_result = 10; + + private TestResultsScreen resultsScreen; + private int currentScoreId; + private bool requestComplete; [SetUp] public void Setup() => Schedule(() => { - roomsReceived = false; + currentScoreId = 0; + requestComplete = false; bindHandler(); }); [Test] - public void TestShowResultsWithScore() + public void TestShowWithUserScore() { - createResults(new TestScoreInfo(new OsuRuleset().RulesetInfo)); - AddWaitStep("wait for display", 5); + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(userScore: userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); } [Test] - public void TestShowResultsNullScore() + public void TestShowNullUserScore() { - createResults(null); - AddWaitStep("wait for display", 5); + createResults(); + waitForDisplay(); + + AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } [Test] - public void TestShowResultsNullScoreWithDelay() + public void TestShowUserScoreWithDelay() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(3000, userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); + } + + [Test] + public void TestShowNullUserScoreWithDelay() { AddStep("bind delayed handler", () => bindHandler(3000)); - createResults(null); - AddUntilStep("wait for rooms to be received", () => roomsReceived); - AddWaitStep("wait for display", 5); + + createResults(); + waitForDisplay(); + + AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } - private void createResults(ScoreInfo score) + [Test] + public void TestFetchWhenScrolledToTheRight() + { + createResults(); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(3000)); + + for (int i = 0; i < 2; i++) + { + int beforePanelCount = 0; + + AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); + AddStep("scroll to right", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToEnd(false)); + + AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); + waitForDisplay(); + + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); + } + } + + [Test] + public void TestFetchWhenScrolledToTheLeft() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(userScore: userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(3000)); + + for (int i = 0; i < 2; i++) + { + int beforePanelCount = 0; + + AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); + AddStep("scroll to left", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToStart(false)); + + AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible); + waitForDisplay(); + + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden); + } + } + + private void createResults(Func getScore = null) { AddStep("load results", () => { - LoadScreen(new TimeshiftResultsScreen(score, 1, new PlaylistItem + LoadScreen(resultsScreen = new TestResultsScreen(getScore?.Invoke(), 1, new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo } @@ -63,62 +163,214 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private void bindHandler(double delay = 0) + private void waitForDisplay() { - var roomScores = new List(); + AddUntilStep("wait for request to complete", () => requestComplete); + AddWaitStep("wait for display", 5); + } - for (int i = 0; i < 10; i++) + private void bindHandler(double delay = 0, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => + { + requestComplete = false; + + if (failRequests) { - roomScores.Add(new RoomScore + triggerFail(request, delay); + return; + } + + switch (request) + { + case ShowPlaylistUserScoreRequest s: + if (userScore == null) + triggerFail(s, delay); + else + triggerSuccess(s, createUserResponse(userScore), delay); + break; + + case IndexPlaylistScoresRequest i: + triggerSuccess(i, createIndexResponse(i), delay); + break; + } + }; + + private void triggerSuccess(APIRequest req, T result, double delay) + where T : class + { + if (delay == 0) + success(); + else + { + Task.Run(async () => { - ID = i, - Accuracy = 0.9 - 0.01 * i, - EndedAt = DateTimeOffset.Now.Subtract(TimeSpan.FromHours(i)), + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + Schedule(success); + }); + } + + void success() + { + requestComplete = true; + req.TriggerSuccess(result); + } + } + + private void triggerFail(APIRequest req, double delay) + { + if (delay == 0) + fail(); + else + { + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + Schedule(fail); + }); + } + + void fail() + { + requestComplete = true; + req.TriggerFailure(new WebException("Failed.")); + } + } + + private MultiplayerScore createUserResponse([NotNull] ScoreInfo userScore) + { + var multiplayerUserScore = new MultiplayerScore + { + ID = (int)(userScore.OnlineScoreID ?? currentScoreId++), + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = userScore.Passed, + Rank = userScore.Rank, + Position = 200, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore, + User = userScore.User, + Statistics = userScore.Statistics, + ScoresAround = new MultiplayerScoresAround + { + Higher = new MultiplayerScores(), + Lower = new MultiplayerScores() + } + }; + + for (int i = 1; i <= scores_per_result; i++) + { + multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, Passed = true, - Rank = ScoreRank.B, - MaxCombo = 999, - TotalScore = 999999 - i * 1000, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore - i, User = new User { Id = 2, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, - Statistics = + Statistics = userScore.Statistics + }); + + multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = true, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore + i, + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = userScore.Statistics + }); + } + + addCursor(multiplayerUserScore.ScoresAround.Lower); + addCursor(multiplayerUserScore.ScoresAround.Higher); + + return multiplayerUserScore; + } + + private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req) + { + var result = new IndexedMultiplayerScores(); + + long startTotalScore = req.Cursor?.Properties["total_score"].ToObject() ?? 1000000; + string sort = req.IndexParams?.Properties["sort"].ToObject() ?? "score_desc"; + + for (int i = 1; i <= scores_per_result; i++) + { + result.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.X, + MaxCombo = 1000, + TotalScore = startTotalScore + (sort == "score_asc" ? i : -i), + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = new Dictionary { { HitResult.Miss, 1 }, { HitResult.Meh, 50 }, { HitResult.Good, 100 }, - { HitResult.Great, 300 }, + { HitResult.Great, 300 } } }); } - ((DummyAPIAccess)API).HandleRequest = request => + addCursor(result); + + return result; + } + + private void addCursor(MultiplayerScores scores) + { + scores.Cursor = new Cursor { - switch (request) + Properties = new Dictionary { - case GetRoomPlaylistScoresRequest r: - if (delay == 0) - success(); - else - { - Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMilliseconds(delay)); - Schedule(success); - }); - } + { "total_score", JToken.FromObject(scores.Scores[^1].TotalScore) }, + { "score_id", JToken.FromObject(scores.Scores[^1].ID) }, + } + }; - void success() - { - r.TriggerSuccess(new RoomPlaylistScores { Scores = roomScores }); - roomsReceived = true; - } - - break; + scores.Params = new IndexScoresParams + { + Properties = new Dictionary + { + { "sort", JToken.FromObject(scores.Scores[^1].TotalScore > scores.Scores[^2].TotalScore ? "score_asc" : "score_desc") } } }; } + + private class TestResultsScreen : TimeshiftResultsScreen + { + public new LoadingSpinner LeftSpinner => base.LeftSpinner; + public new LoadingSpinner CentreSpinner => base.CentreSpinner; + public new LoadingSpinner RightSpinner => base.RightSpinner; + public new ScorePanelList ScorePanelList => base.ScorePanelList; + + public TestResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + : base(score, roomId, playlistItem, allowRetry) + { + } + } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 9d603ac471..8ccaca8630 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.Mods; @@ -70,6 +71,23 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Ensure time wasn't reset to preview point", () => track().CurrentTime < beatmap().Metadata.PreviewTime); } + [Test] + public void TestMenuMakesMusic() + { + WorkingBeatmap beatmap() => Game.Beatmap.Value; + Track track() => beatmap().Track; + + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + + AddUntilStep("wait for no track", () => track() is TrackVirtual); + + AddStep("return to menu", () => songSelect.Exit()); + + AddUntilStep("wait for track", () => !(track() is TrackVirtual) && track().IsRunning); + } + [Test] public void TestExitSongSelectWithClick() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs new file mode 100644 index 0000000000..17675bfbc0 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Overlays.News; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osuTK; +using System; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneNewsCard : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneNewsCard() + { + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new[] + { + new NewsCard(new APINewsPost + { + Title = "This post has an image which starts with \"/\" and has many authors! (clickable)", + Preview = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + Author = "someone, someone1, someone2, someone3, someone4", + FirstImage = "/help/wiki/shared/news/banners/monthly-beatmapping-contest.png", + PublishedAt = DateTimeOffset.Now, + Slug = "2020-07-16-summer-theme-park-2020-voting-open" + }), + new NewsCard(new APINewsPost + { + Title = "This post has a full-url image! (HTML entity: &) (non-clickable)", + Preview = "boom (HTML entity: &)", + Author = "user (HTML entity: &)", + FirstImage = "https://assets.ppy.sh/artists/88/header.jpg", + PublishedAt = DateTimeOffset.Now + }) + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs new file mode 100644 index 0000000000..78288bf6e4 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Overlays.News; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneNewsHeader : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private TestHeader header; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = header = new TestHeader + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + + [Test] + public void TestControl() + { + AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("1 tab total", () => header.TabCount == 1); + + AddStep("Set article 1", () => header.SetArticle("1")); + AddAssert("Article 1 selected", () => header.Current.Value == "1"); + AddAssert("2 tabs total", () => header.TabCount == 2); + + AddStep("Set article 2", () => header.SetArticle("2")); + AddAssert("Article 2 selected", () => header.Current.Value == "2"); + AddAssert("2 tabs total", () => header.TabCount == 2); + + AddStep("Set front page", () => header.SetFrontPage()); + AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("1 tab total", () => header.TabCount == 1); + } + + private class TestHeader : NewsHeader + { + public int TabCount => TabControl.Items.Count; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs index d47c972564..37d51c16d2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs @@ -2,65 +2,64 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Graphics; +using NUnit.Framework; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; -using osu.Game.Overlays.News; namespace osu.Game.Tests.Visual.Online { public class TestSceneNewsOverlay : OsuTestScene { - private TestNewsOverlay news; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - protected override void LoadComplete() + private NewsOverlay news; + + [SetUp] + public void SetUp() => Schedule(() => Child = news = new NewsOverlay()); + + [Test] + public void TestRequest() { - base.LoadComplete(); - Add(news = new TestNewsOverlay()); - AddStep(@"Show", news.Show); - AddStep(@"Hide", news.Hide); - - AddStep(@"Show front page", () => news.ShowFrontPage()); - AddStep(@"Custom article", () => news.Current.Value = "Test Article 101"); - - AddStep(@"Article covers", () => news.LoadAndShowContent(new NewsCoverTest())); + setUpNewsResponse(responseExample); + AddStep("Show", () => news.Show()); + AddStep("Show article", () => news.ShowArticle("article")); } - private class TestNewsOverlay : NewsOverlay - { - public new void LoadAndShowContent(NewsContent content) => base.LoadAndShowContent(content); - } - - private class NewsCoverTest : NewsContent - { - public NewsCoverTest() + private void setUpNewsResponse(GetNewsResponse r) + => AddStep("set up response", () => { - Spacing = new osuTK.Vector2(0, 10); - - var article = new NewsArticleCover.ArticleInfo + dummyAPI.HandleRequest = request => { - Author = "Ephemeral", - CoverUrl = "https://assets.ppy.sh/artists/58/header.jpg", - Time = new DateTime(2019, 12, 4), - Title = "New Featured Artist: Kurokotei" - }; + if (!(request is GetNewsRequest getNewsRequest)) + return; - Children = new Drawable[] - { - new NewsArticleCover(article) - { - Height = 200 - }, - new NewsArticleCover(article) - { - Height = 120 - }, - new NewsArticleCover(article) - { - RelativeSizeAxes = Axes.None, - Size = new osuTK.Vector2(400, 200), - } + getNewsRequest.TriggerSuccess(r); }; + }); + + private GetNewsResponse responseExample => new GetNewsResponse + { + NewsPosts = new[] + { + new APINewsPost + { + Title = "This post has an image which starts with \"/\" and has many authors!", + Preview = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + Author = "someone, someone1, someone2, someone3, someone4", + FirstImage = "/help/wiki/shared/news/banners/monthly-beatmapping-contest.png", + PublishedAt = DateTimeOffset.Now + }, + new APINewsPost + { + Title = "This post has a full-url image! (HTML entity: &)", + Preview = "boom (HTML entity: &)", + Author = "user (HTML entity: &)", + FirstImage = "https://assets.ppy.sh/artists/88/header.jpg", + PublishedAt = DateTimeOffset.Now + } } - } + }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs index 997db827f3..d60222fa0b 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs @@ -30,12 +30,6 @@ namespace osu.Game.Tests.Visual.Online Add(selector = new SpotlightSelector()); } - [Test] - public void TestVisibility() - { - AddStep("Toggle Visibility", selector.ToggleVisibility); - } - [Test] public void TestLocalSpotlights() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs index a3b102dc76..ee109189c7 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Catch; using osu.Framework.Allocation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; namespace osu.Game.Tests.Visual.Online { @@ -105,7 +106,7 @@ namespace osu.Game.Tests.Visual.Online { onLoadStarted(); - request = new GetSpotlightRankingsRequest(ruleset, spotlight); + request = new GetSpotlightRankingsRequest(ruleset, spotlight, RankingsSortCriteria.All); ((GetSpotlightRankingsRequest)request).Success += rankings => Schedule(() => { var table = new ScoresTable(1, rankings.Users); diff --git a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs index 273f593c32..18ac415126 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs @@ -4,19 +4,22 @@ using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; -using osu.Game.Graphics; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { public class TestSceneShowMoreButton : OsuTestScene { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + public TestSceneShowMoreButton() { - TestButton button = null; + ShowMoreButton button = null; int fireCount = 0; - Add(button = new TestButton + Add(button = new ShowMoreButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -46,16 +49,5 @@ namespace osu.Game.Tests.Visual.Online AddAssert("action fired twice", () => fireCount == 2); AddAssert("is in loading state", () => button.IsLoading); } - - private class TestButton : ShowMoreButton - { - [BackgroundDependencyLoader] - private void load(OsuColour colors) - { - IdleColour = colors.YellowDark; - HoverColour = colors.Yellow; - ChevronIconColour = colors.Red; - } - } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs deleted file mode 100644 index 77e77d90c1..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using NUnit.Framework; -using osu.Game.Overlays; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Online -{ - [TestFixture] - public class TestSceneSocialOverlay : OsuTestScene - { - protected override bool UseOnlineAPI => true; - - public TestSceneSocialOverlay() - { - SocialOverlay s = new SocialOverlay - { - Users = new[] - { - new User - { - Username = @"flyte", - Id = 3103765, - Country = new Country { FlagName = @"JP" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", - }, - new User - { - Username = @"Cookiezi", - Id = 124493, - Country = new Country { FlagName = @"KR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - }, - new User - { - Username = @"Angelsim", - Id = 1777162, - Country = new Country { FlagName = @"KR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", - }, - new User - { - Username = @"Rafis", - Id = 2558286, - Country = new Country { FlagName = @"PL" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg", - }, - new User - { - Username = @"hvick225", - Id = 50265, - Country = new Country { FlagName = @"TW" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c5.jpg", - }, - new User - { - Username = @"peppy", - Id = 2, - Country = new Country { FlagName = @"AU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" - }, - new User - { - Username = @"filsdelama", - Id = 2831793, - Country = new Country { FlagName = @"FR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c7.jpg" - }, - new User - { - Username = @"_index", - Id = 652457, - Country = new Country { FlagName = @"RU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c8.jpg" - }, - }, - }; - Add(s); - - AddStep(@"toggle", s.ToggleVisibility); - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index f763e50067..c2e9945c99 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -42,6 +42,19 @@ namespace osu.Game.Tests.Visual.Online Spacing = new Vector2(10f), Children = new Drawable[] { + new UserBrickPanel(new User + { + Username = @"flyte", + Id = 3103765, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + }), + new UserBrickPanel(new User + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }), flyte = new UserGridPanel(new User { Username = @"flyte", diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs new file mode 100644 index 0000000000..7ca1fc842f --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneHitEventTimingDistributionGraph : OsuTestScene + { + [Test] + public void TestManyDistributedEvents() + { + createTest(CreateDistributedHitEvents()); + } + + [Test] + public void TestZeroTimeOffset() + { + createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList()); + } + + [Test] + public void TestNoEvents() + { + createTest(new List()); + } + + private void createTest(List events) => AddStep("create test", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + new HitEventTimingDistributionGraph(events) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(600, 130) + } + }; + }); + + public static List CreateDistributedHitEvents() + { + var hitEvents = new List(); + + for (int i = 0; i < 50; i++) + { + int count = (int)(Math.Pow(25 - Math.Abs(i - 25), 2)); + + for (int j = 0; j < count; j++) + hitEvents.Add(new HitEvent(i - 25, HitResult.Perfect, new HitCircle(), new HitCircle(), null)); + } + + return hitEvents; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 125aa0a1e7..74808bc2f5 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -1,23 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Ranking { [TestFixture] - public class TestSceneResultsScreen : ScreenTestScene + public class TestSceneResultsScreen : OsuManualInputManagerTestScene { private BeatmapManager beatmaps; @@ -41,7 +50,7 @@ namespace osu.Game.Tests.Visual.Ranking private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); [Test] - public void ResultsWithoutPlayer() + public void TestResultsWithoutPlayer() { TestResultsScreen screen = null; OsuScreenStack stack; @@ -60,7 +69,7 @@ namespace osu.Game.Tests.Visual.Ranking } [Test] - public void ResultsWithPlayer() + public void TestResultsWithPlayer() { TestResultsScreen screen = null; @@ -70,7 +79,7 @@ namespace osu.Game.Tests.Visual.Ranking } [Test] - public void ResultsForUnranked() + public void TestResultsForUnranked() { UnrankedSoloResultsScreen screen = null; @@ -79,6 +88,130 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("retry overlay present", () => screen.RetryOverlay != null); } + [Test] + public void TestShowHideStatisticsViaOutsideClick() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddUntilStep("expanded panel at the left of the screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150; + }); + + AddStep("click to right of panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel.ScreenSpaceDrawQuad.TopRight + new Vector2(100, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + + [Test] + public void TestShowHideStatistics() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddUntilStep("expanded panel at the left of the screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150; + }); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + + [Test] + public void TestShowStatisticsAndClickOtherPanel() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + ScorePanel expandedPanel = null; + ScorePanel contractedPanel = null; + + AddStep("click expanded panel then contracted panel", () => + { + expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + + contractedPanel = this.ChildrenOfType().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X); + InputManager.MoveMouseTo(contractedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddAssert("contracted panel still contracted", () => contractedPanel.State == PanelState.Contracted); + AddAssert("expanded panel still expanded", () => expandedPanel.State == PanelState.Expanded); + } + + [Test] + public void TestFetchScoresAfterShowingStatistics() + { + DelayedFetchResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo), 3000))); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for fetch", () => screen.FetchCompleted); + AddAssert("expanded panel still on screen", () => this.ChildrenOfType().Single(p => p.State == PanelState.Expanded).ScreenSpaceDrawQuad.TopLeft.X > 0); + } + private class TestResultsContainer : Container { [Cached(typeof(Player))] @@ -113,6 +246,58 @@ namespace osu.Game.Tests.Visual.Ranking RetryOverlay = InternalChildren.OfType().SingleOrDefault(); } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + var scores = new List(); + + for (int i = 0; i < 20; i++) + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + score.TotalScore += 10 - i; + scores.Add(score); + } + + scoresCallback?.Invoke(scores); + + return null; + } + } + + private class DelayedFetchResultsScreen : TestResultsScreen + { + public bool FetchCompleted { get; private set; } + + private readonly double delay; + + public DelayedFetchResultsScreen(ScoreInfo score, double delay) + : base(score) + { + this.delay = delay; + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + + var scores = new List(); + + for (int i = 0; i < 20; i++) + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + score.TotalScore += 10 - i; + scores.Add(score); + } + + scoresCallback?.Invoke(scores); + + Schedule(() => FetchCompleted = true); + }); + + return null; + } } private class UnrankedSoloResultsScreen : SoloResultsScreen diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs new file mode 100644 index 0000000000..8700fbeb42 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneStatisticsPanel : OsuTestScene + { + [Test] + public void TestScoreWithStatistics() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents() + }; + + loadPanel(score); + } + + [Test] + public void TestScoreWithoutStatistics() + { + loadPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + } + + [Test] + public void TestNullScore() + { + loadPanel(null); + } + + private void loadPanel(ScoreInfo score) => AddStep("load panel", () => + { + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score } + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 073d75692e..a3ea4619cc 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -80,9 +80,9 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestRecommendedSelection() { - loadBeatmaps(); + loadBeatmaps(carouselAdjust: carousel => carousel.GetRecommendedBeatmap = beatmaps => beatmaps.LastOrDefault()); - AddStep("set recommendation function", () => carousel.GetRecommendedBeatmap = beatmaps => beatmaps.LastOrDefault()); + AddStep("select last", () => carousel.SelectBeatmap(carousel.BeatmapSets.Last().Beatmaps.Last())); // check recommended was selected advanceSelection(direction: 1, diff: false); @@ -114,7 +114,7 @@ namespace osu.Game.Tests.Visual.SongSelect { loadBeatmaps(); - advanceSelection(direction: 1, diff: false); + AddStep("select first", () => carousel.SelectBeatmap(carousel.BeatmapSets.First().Beatmaps.First())); waitForSelection(1, 1); advanceSelection(direction: 1, diff: true); @@ -707,9 +707,9 @@ namespace osu.Game.Tests.Visual.SongSelect checkVisibleItemCount(true, 15); } - private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null) + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null) { - createCarousel(); + createCarousel(carouselAdjust); if (beatmapSets == null) { @@ -730,17 +730,21 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Wait for load", () => changed); } - private void createCarousel(Container target = null) + private void createCarousel(Action carouselAdjust = null, Container target = null) { AddStep("Create carousel", () => { selectedSets.Clear(); eagerSelectedIDs.Clear(); - (target ?? this).Child = carousel = new TestBeatmapCarousel + carousel = new TestBeatmapCarousel { RelativeSizeAxes = Axes.Both, }; + + carouselAdjust?.Invoke(carousel); + + (target ?? this).Child = carousel; }); } diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index 22ae5257e7..b347c39c1e 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -44,6 +44,7 @@ namespace osu.Game.Tests.Visual typeof(NotificationOverlay), typeof(BeatmapListingOverlay), typeof(DashboardOverlay), + typeof(NewsOverlay), typeof(ChannelManager), typeof(ChatOverlay), typeof(SettingsOverlay), diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs new file mode 100644 index 0000000000..c2ac5179c9 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Overlays.Comments.Buttons; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osu.Framework.Graphics.Containers; +using osuTK; +using NUnit.Framework; +using System.Linq; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneCommentRepliesButton : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly TestButton button; + + public TestSceneCommentRepliesButton() + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + button = new TestButton(), + new LoadRepliesButton + { + Action = () => { } + }, + new ShowRepliesButton(1), + new ShowRepliesButton(2) + } + }; + } + + [Test] + public void TestArrowDirection() + { + AddStep("Set upwards", () => button.SetIconDirection(true)); + AddAssert("Icon facing upwards", () => button.Icon.Scale.Y == -1); + AddStep("Set downwards", () => button.SetIconDirection(false)); + AddAssert("Icon facing downwards", () => button.Icon.Scale.Y == 1); + } + + private class TestButton : CommentRepliesButton + { + public SpriteIcon Icon => this.ChildrenOfType().First(); + + public TestButton() + { + Text = "sample text"; + } + + public new void SetIconDirection(bool upwards) => base.SetIconDirection(upwards); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs new file mode 100644 index 0000000000..c51204eaba --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Overlays.Dashboard.Home; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using osu.Game.Users; +using System; +using osu.Framework.Graphics.Shapes; +using System.Collections.Generic; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneDashboardBeatmapListing : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly Container content; + + public TestSceneDashboardBeatmapListing() + { + Add(content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Width = 300, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10 }, + Child = new DashboardBeatmapListing(new_beatmaps, popular_beatmaps) + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep("Set width to 500", () => content.ResizeWidthTo(500, 500)); + AddStep("Set width to 300", () => content.ResizeWidthTo(300, 500)); + } + + private static readonly List new_beatmaps = new List + { + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Very Long Title (TV size) [TATOE]", + Artist = "This artist has a really long name how is this possible", + Author = new User + { + Username = "author", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg?1595456608", + }, + Ranked = DateTimeOffset.Now + } + }, + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Very Long Title (TV size) [TATOE]", + Artist = "This artist has a really long name how is this possible", + Author = new User + { + Username = "author", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg?1595456608", + }, + Ranked = DateTimeOffset.MinValue + } + } + }; + + private static readonly List popular_beatmaps = new List + { + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Title", + Artist = "Artist", + Author = new User + { + Username = "author", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg?1595295586", + }, + FavouriteCount = 100 + } + }, + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Title 2", + Artist = "Artist 2", + Author = new User + { + Username = "someone", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg?1595295586", + }, + FavouriteCount = 10 + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs new file mode 100644 index 0000000000..155d043bf9 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneLogoAnimation : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + LogoAnimation anim2; + + Add(anim2 = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Intro/Triangles/logo-highlight"), + Colour = Colour4.White, + }); + + LogoAnimation anim; + + Add(anim = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }); + + AddSliderStep("Progress", 0f, 1f, 0f, newValue => + { + anim2.AnimationProgress = newValue; + anim.AnimationProgress = newValue; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index 010e4330d7..5582cc6826 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -263,7 +264,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void moveLogoFacade() { - if (logoFacade?.Transforms.Count == 0 && transferContainer?.Transforms.Count == 0) + if (!(logoFacade?.Transforms).Any() && !(transferContainer?.Transforms).Any()) { Random random = new Random(); trackingContainer.Delay(500).MoveTo(new Vector2(random.Next(0, (int)logo.Parent.DrawWidth), random.Next(0, (int)logo.Parent.DrawHeight)), 300); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index ce691bff70..6f083f4ab6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -13,6 +13,8 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -99,6 +101,12 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestManiaMods() { changeRuleset(3); + + var mania = new ManiaRuleset(); + + testModsWithSameBaseType( + mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModFadeIn)), + mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModHidden))); } [Test] @@ -197,6 +205,18 @@ namespace osu.Game.Tests.Visual.UserInterface checkLabelColor(() => Color4.White); } + private void testModsWithSameBaseType(Mod modA, Mod modB) + { + selectNext(modA); + checkSelected(modA); + selectNext(modB); + checkSelected(modB); + + // Backwards + selectPrevious(modA); + checkSelected(modA); + } + private void selectNext(Mod mod) => AddStep($"left click {mod.Name}", () => modSelect.GetModButton(mod)?.SelectNext(1)); private void selectPrevious(Mod mod) => AddStep($"right click {mod.Name}", () => modSelect.GetModButton(mod)?.SelectNext(-1)); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs deleted file mode 100644 index 97a3f62b2d..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public class TestSceneNumberBox : OsuTestScene - { - private OsuNumberBox numberBox; - - [BackgroundDependencyLoader] - private void load() - { - Child = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 250 }, - Child = numberBox = new OsuNumberBox - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - PlaceholderText = "Insert numbers here" - } - }; - - clearInput(); - AddStep("enter numbers", () => numberBox.Text = "987654321"); - expectedValue("987654321"); - clearInput(); - AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); - expectedValue("123"); - clearInput(); - } - - private void clearInput() => AddStep("clear input", () => numberBox.Text = null); - - private void expectedValue(string value) => AddAssert("expect number", () => numberBox.Text == value); - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs new file mode 100644 index 0000000000..756928d3ec --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuTextBox : OsuTestScene + { + private readonly OsuNumberBox numberBox; + + public TestSceneOsuTextBox() + { + Child = new Container + { + Masking = true, + CornerRadius = 10f, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(15f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.DarkSlateGray, + Alpha = 0.75f, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(50f), + Spacing = new Vector2(0f, 50f), + Children = new[] + { + new OsuTextBox + { + Width = 500f, + PlaceholderText = "Normal textbox", + }, + new OsuPasswordTextBox + { + Width = 500f, + PlaceholderText = "Password textbox", + }, + numberBox = new OsuNumberBox + { + Width = 500f, + PlaceholderText = "Number textbox" + } + } + } + } + }; + } + + [Test] + public void TestNumberBox() + { + clearTextbox(numberBox); + AddStep("enter numbers", () => numberBox.Text = "987654321"); + expectedValue(numberBox, "987654321"); + + clearTextbox(numberBox); + AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); + expectedValue(numberBox, "123"); + + clearTextbox(numberBox); + } + + private void clearTextbox(OsuTextBox textBox) => AddStep("clear textbox", () => textBox.Text = null); + private void expectedValue(OsuTextBox textBox, string value) => AddAssert("expected textbox value", () => textBox.Text == value); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs index 60af5b37ef..2a76b8e265 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs @@ -36,11 +36,11 @@ namespace osu.Game.Tests.Visual.UserInterface } }); - addHeader("Orange OverlayHeader (no background)", new TestNoBackgroundHeader(), OverlayColourScheme.Orange); - addHeader("Blue OverlayHeader", new TestNoControlHeader(), OverlayColourScheme.Blue); + addHeader("Orange OverlayHeader (no background, 100 padding)", new TestNoBackgroundHeader(), OverlayColourScheme.Orange); + addHeader("Blue OverlayHeader (default 50 padding)", new TestNoControlHeader(), OverlayColourScheme.Blue); addHeader("Green TabControlOverlayHeader (string) with ruleset selector", new TestStringTabControlHeader(), OverlayColourScheme.Green); - addHeader("Pink TabControlOverlayHeader (enum)", new TestEnumTabControlHeader(), OverlayColourScheme.Pink); - addHeader("Red BreadcrumbControlOverlayHeader (no background)", new TestBreadcrumbControlHeader(), OverlayColourScheme.Red); + addHeader("Pink TabControlOverlayHeader (enum, 30 padding)", new TestEnumTabControlHeader(), OverlayColourScheme.Pink); + addHeader("Red BreadcrumbControlOverlayHeader (no background, 10 padding)", new TestBreadcrumbControlHeader(), OverlayColourScheme.Red); } private void addHeader(string name, OverlayHeader header, OverlayColourScheme colourScheme) @@ -86,6 +86,11 @@ namespace osu.Game.Tests.Visual.UserInterface private class TestNoBackgroundHeader : OverlayHeader { protected override OverlayTitle CreateTitle() => new TestTitle(); + + public TestNoBackgroundHeader() + { + ContentSidePadding = 100; + } } private class TestNoControlHeader : OverlayHeader @@ -112,6 +117,11 @@ namespace osu.Game.Tests.Visual.UserInterface private class TestEnumTabControlHeader : TabControlOverlayHeader { + public TestEnumTabControlHeader() + { + ContentSidePadding = 30; + } + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/rankings"); protected override OverlayTitle CreateTitle() => new TestTitle(); @@ -130,6 +140,8 @@ namespace osu.Game.Tests.Visual.UserInterface public TestBreadcrumbControlHeader() { + ContentSidePadding = 10; + TabControl.AddItem("tab1"); TabControl.AddItem("tab2"); TabControl.Current.Value = "tab2"; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs new file mode 100644 index 0000000000..24bc0dbc97 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneRankingsSortTabControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneRankingsSortTabControl() + { + Child = new RankingsSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + } +} diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 5ee887cb64..4b0506d818 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index aa37326a49..f256b8e4e9 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -7,7 +7,7 @@ - + WinExe diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index fc7fcef892..cafec0a88b 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; @@ -66,6 +67,9 @@ namespace osu.Game.Tournament.Components } } + // Todo: This is a hack for https://github.com/ppy/osu-framework/issues/3617 since this container is at the very edge of the screen and potentially initially masked away. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + [BackgroundDependencyLoader] private void load() { @@ -77,8 +81,6 @@ namespace osu.Game.Tournament.Components flow = new FillFlowContainer { RelativeSizeAxes = Axes.X, - // Todo: This is a hack for https://github.com/ppy/osu-framework/issues/3617 since this container is at the very edge of the screen and potentially initially masked away. - Height = 1, AutoSizeAxes = Axes.Y, LayoutDuration = 500, LayoutEasing = Easing.OutQuint, diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs index 4aea7ff4c0..fa530ea2c4 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs @@ -81,7 +81,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { } - private class SettingsRoundDropdown : LadderSettingsDropdown + private class SettingsRoundDropdown : SettingsDropdown { public SettingsRoundDropdown(BindableList rounds) { diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderSettingsDropdown.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderSettingsDropdown.cs deleted file mode 100644 index 347e4d91e0..0000000000 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderSettingsDropdown.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Settings; - -namespace osu.Game.Tournament.Screens.Ladder.Components -{ - public class LadderSettingsDropdown : SettingsDropdown - { - protected override OsuDropdown CreateDropdown() => new DropdownControl(); - - private new class DropdownControl : SettingsDropdown.DropdownControl - { - protected override DropdownMenu CreateMenu() => new Menu(); - - private new class Menu : OsuDropdownMenu - { - public Menu() - { - MaxHeight = 200; - } - } - } - } -} diff --git a/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs index a630e51e44..6604e3a313 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs @@ -6,11 +6,12 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Overlays.Settings; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class SettingsTeamDropdown : LadderSettingsDropdown + public class SettingsTeamDropdown : SettingsDropdown { public SettingsTeamDropdown(BindableList teams) { diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index d48e396b89..eed3cac9f0 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -203,13 +203,14 @@ namespace osu.Game.Tournament.Screens.TeamIntro new Box { RelativeSizeAxes = Axes.Both, - Colour = TournamentGame.TEXT_COLOUR, + Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, }, new TournamentSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = seeding.ToString("#,0"), + Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR }, } }, diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index 99e0bf4e33..11fee030f8 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -57,7 +57,7 @@ namespace osu.Game.Beatmaps beatmap.BeatmapInfo = original.BeatmapInfo; beatmap.ControlPointInfo = original.ControlPointInfo; - beatmap.HitObjects = convertHitObjects(original.HitObjects, original); + beatmap.HitObjects = convertHitObjects(original.HitObjects, original).OrderBy(s => s.StartTime).ToList(); beatmap.Breaks = original.Breaks; return beatmap; diff --git a/osu.Game/Beatmaps/BeatmapDifficultyManager.cs b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs new file mode 100644 index 0000000000..b80b4e45ed --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs @@ -0,0 +1,303 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Lists; +using osu.Framework.Threading; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Beatmaps +{ + public class BeatmapDifficultyManager : CompositeDrawable + { + // Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes. + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(BeatmapDifficultyManager)); + + // A permanent cache to prevent re-computations. + private readonly ConcurrentDictionary difficultyCache = new ConcurrentDictionary(); + + // All bindables that should be updated along with the current ruleset + mods. + private readonly LockedWeakList trackedBindables = new LockedWeakList(); + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private Bindable currentRuleset { get; set; } + + [Resolved] + private Bindable> currentMods { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentRuleset.BindValueChanged(_ => updateTrackedBindables()); + currentMods.BindValueChanged(_ => updateTrackedBindables(), true); + } + + /// + /// Retrieves a bindable containing the star difficulty of a that follows the currently-selected ruleset and mods. + /// + /// The to get the difficulty of. + /// An optional which stops updating the star difficulty for the given . + /// A bindable that is updated to contain the star difficulty when it becomes available. + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + { + var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); + trackedBindables.Add(bindable); + return bindable; + } + + /// + /// Retrieves a bindable containing the star difficulty of a with a given and combination. + /// + /// + /// The bindable will not update to follow the currently-selected ruleset and mods. + /// + /// The to get the difficulty of. + /// The to get the difficulty with. If null, the 's ruleset is used. + /// The s to get the difficulty with. If null, no mods will be assumed. + /// An optional which stops updating the star difficulty for the given . + /// A bindable that is updated to contain the star difficulty when it becomes available. + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, + CancellationToken cancellationToken = default) + => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); + + /// + /// Retrieves the difficulty of a . + /// + /// The to get the difficulty of. + /// The to get the difficulty with. + /// The s to get the difficulty with. + /// An optional which stops computing the star difficulty. + /// The . + public async Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null, + CancellationToken cancellationToken = default) + { + if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out var existing, out var key)) + return existing; + + return await Task.Factory.StartNew(() => computeDifficulty(key, beatmapInfo, rulesetInfo), cancellationToken, + TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + } + + /// + /// Retrieves the difficulty of a . + /// + /// The to get the difficulty of. + /// The to get the difficulty with. + /// The s to get the difficulty with. + /// The . + public StarDifficulty GetDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null) + { + if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out var existing, out var key)) + return existing; + + return computeDifficulty(key, beatmapInfo, rulesetInfo); + } + + private CancellationTokenSource trackedUpdateCancellationSource; + private readonly List linkedCancellationSources = new List(); + + /// + /// Updates all tracked using the current ruleset and mods. + /// + private void updateTrackedBindables() + { + cancelTrackedBindableUpdate(); + trackedUpdateCancellationSource = new CancellationTokenSource(); + + foreach (var b in trackedBindables) + { + var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken); + linkedCancellationSources.Add(linkedSource); + + updateBindable(b, currentRuleset.Value, currentMods.Value, linkedSource.Token); + } + } + + /// + /// Cancels the existing update of all tracked via . + /// + private void cancelTrackedBindableUpdate() + { + trackedUpdateCancellationSource?.Cancel(); + trackedUpdateCancellationSource = null; + + if (linkedCancellationSources != null) + { + foreach (var c in linkedCancellationSources) + c.Dispose(); + + linkedCancellationSources.Clear(); + } + } + + /// + /// Creates a new and triggers an initial value update. + /// + /// The that star difficulty should correspond to. + /// The initial to get the difficulty with. + /// The initial s to get the difficulty with. + /// An optional which stops updating the star difficulty for the given . + /// The . + private BindableStarDifficulty createBindable([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo initialRulesetInfo, [CanBeNull] IEnumerable initialMods, + CancellationToken cancellationToken) + { + var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); + updateBindable(bindable, initialRulesetInfo, initialMods, cancellationToken); + return bindable; + } + + /// + /// Updates the value of a with a given ruleset + mods. + /// + /// The to update. + /// The to update with. + /// The s to update with. + /// A token that may be used to cancel this update. + private void updateBindable([NotNull] BindableStarDifficulty bindable, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) + { + GetDifficultyAsync(bindable.Beatmap, rulesetInfo, mods, cancellationToken).ContinueWith(t => + { + // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. + Schedule(() => + { + if (!cancellationToken.IsCancellationRequested) + bindable.Value = t.Result; + }); + }, cancellationToken); + } + + /// + /// Computes the difficulty defined by a key, and stores it to the timed cache. + /// + /// The that defines the computation parameters. + /// The to compute the difficulty of. + /// The to compute the difficulty with. + /// The . + private StarDifficulty computeDifficulty(in DifficultyCacheLookup key, BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo) + { + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + rulesetInfo ??= beatmapInfo.Ruleset; + + try + { + var ruleset = rulesetInfo.CreateInstance(); + Debug.Assert(ruleset != null); + + var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(beatmapInfo)); + var attributes = calculator.Calculate(key.Mods); + + return difficultyCache[key] = new StarDifficulty(attributes.StarRating); + } + catch + { + return difficultyCache[key] = new StarDifficulty(0); + } + } + + /// + /// Attempts to retrieve an existing difficulty for the combination. + /// + /// The . + /// The . + /// The s. + /// The existing difficulty value, if present. + /// The key that was used to perform this lookup. This can be further used to query . + /// Whether an existing difficulty was found. + private bool tryGetExisting(BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo, IEnumerable mods, out StarDifficulty existingDifficulty, out DifficultyCacheLookup key) + { + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + rulesetInfo ??= beatmapInfo.Ruleset; + + // Difficulty can only be computed if the beatmap and ruleset are locally available. + if (beatmapInfo.ID == 0 || rulesetInfo.ID == null) + { + // If not, fall back to the existing star difficulty (e.g. from an online source). + existingDifficulty = new StarDifficulty(beatmapInfo.StarDifficulty); + key = default; + + return true; + } + + key = new DifficultyCacheLookup(beatmapInfo.ID, rulesetInfo.ID.Value, mods); + return difficultyCache.TryGetValue(key, out existingDifficulty); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + cancelTrackedBindableUpdate(); + updateScheduler?.Dispose(); + } + + private readonly struct DifficultyCacheLookup : IEquatable + { + public readonly int BeatmapId; + public readonly int RulesetId; + public readonly Mod[] Mods; + + public DifficultyCacheLookup(int beatmapId, int rulesetId, IEnumerable mods) + { + BeatmapId = beatmapId; + RulesetId = rulesetId; + Mods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); + } + + public bool Equals(DifficultyCacheLookup other) + => BeatmapId == other.BeatmapId + && RulesetId == other.RulesetId + && Mods.SequenceEqual(other.Mods); + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(BeatmapId); + hashCode.Add(RulesetId); + foreach (var mod in Mods) + hashCode.Add(mod.Acronym); + + return hashCode.ToHashCode(); + } + } + + private class BindableStarDifficulty : Bindable + { + public readonly BeatmapInfo Beatmap; + public readonly CancellationToken CancellationToken; + + public BindableStarDifficulty(BeatmapInfo beatmap, CancellationToken cancellationToken) + { + Beatmap = beatmap; + CancellationToken = cancellationToken; + } + } + } + + public readonly struct StarDifficulty + { + public readonly double Stars; + + public StarDifficulty(double stars) + { + Stars = stars; + + // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 2cf3a21975..b4b341634c 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -240,6 +240,9 @@ namespace osu.Game.Beatmaps beatmapInfo = QueryBeatmap(b => b.ID == info.ID); } + if (beatmapInfo == null) + return DefaultBeatmap; + lock (workingCache) { var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID); @@ -280,14 +283,16 @@ namespace osu.Game.Beatmaps /// Returns a list of all usable s. /// /// A list of available . - public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All) => GetAllUsableBeatmapSetsEnumerable(includes).ToList(); + public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => + GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList(); /// /// Returns a list of all usable s. Note that files are not populated. /// /// The level of detail to include in the returned objects. + /// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases. /// A list of available . - public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes) + public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) { IQueryable queryable; @@ -309,7 +314,7 @@ namespace osu.Game.Beatmaps // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY // clause which causes queries to take 5-10x longer. // TODO: remove if upgrading to EF core 3.x. - return queryable.AsEnumerable().Where(s => !s.DeletePending && !s.Protected); + return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected)); } /// diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index d47d37806e..16207c7d2a 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -48,16 +48,13 @@ namespace osu.Game.Beatmaps public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) { - if (api?.State != APIState.Online) - return Task.CompletedTask; - LogForModel(beatmapSet, "Performing online lookups..."); return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); } // todo: expose this when we need to do individual difficulty lookups. protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken) - => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler, updateScheduler); + => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap) { @@ -183,6 +180,7 @@ namespace osu.Game.Beatmaps public void Dispose() { cacheDownloadRequest?.Dispose(); + updateScheduler?.Dispose(); } [Serializable] diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index af6ca24165..e7788b75f3 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -64,49 +64,49 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The time to find the difficulty control point at. /// The difficulty control point. - public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time); + public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); /// /// Finds the effect control point that is active at . /// /// The time to find the effect control point at. /// The effect control point. - public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time); + public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT); /// /// Finds the sound control point that is active at . /// /// The time to find the sound control point at. /// The sound control point. - public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : null); + public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : SampleControlPoint.DEFAULT); /// /// Finds the timing control point that is active at . /// /// The time to find the timing control point at. /// The timing control point. - public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : null); + public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); /// /// Finds the maximum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMaximum => - 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the minimum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMinimum => - 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the mode BPM (most common BPM) represented by the control points. /// [JsonIgnore] public double BPMMode => - 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Remove all s and return to a pristine state. @@ -170,12 +170,12 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The list to search. /// The time to find the control point at. - /// The control point to use when is before any control points. If null, a new control point will be constructed. + /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. - private T binarySearchWithFallback(IReadOnlyList list, double time, T prePoint = null) - where T : ControlPoint, new() + private T binarySearchWithFallback(IReadOnlyList list, double time, T fallback) + where T : ControlPoint { - return binarySearch(list, time) ?? prePoint ?? new T(); + return binarySearch(list, time) ?? fallback; } /// diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 2448b2b25c..1d38790f87 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -7,6 +7,11 @@ namespace osu.Game.Beatmaps.ControlPoints { public class DifficultyControlPoint : ControlPoint { + public static readonly DifficultyControlPoint DEFAULT = new DifficultyControlPoint + { + SpeedMultiplierBindable = { Disabled = true }, + }; + /// /// The speed multiplier at this control point. /// diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 9b69147468..9e8e3978be 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -7,6 +7,12 @@ namespace osu.Game.Beatmaps.ControlPoints { public class EffectControlPoint : ControlPoint { + public static readonly EffectControlPoint DEFAULT = new EffectControlPoint + { + KiaiModeBindable = { Disabled = true }, + OmitFirstBarLineBindable = { Disabled = true } + }; + /// /// Whether the first bar line of this control point is ignored. /// diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 61851a00d7..c052c04ea0 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -10,6 +10,12 @@ namespace osu.Game.Beatmaps.ControlPoints { public const string DEFAULT_BANK = "normal"; + public static readonly SampleControlPoint DEFAULT = new SampleControlPoint + { + SampleBankBindable = { Disabled = true }, + SampleVolumeBindable = { Disabled = true } + }; + /// /// The default sample bank at this control point. /// diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 1927dd6575..9345299c3a 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -13,6 +13,21 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly Bindable TimeSignatureBindable = new Bindable(TimeSignatures.SimpleQuadruple) { Default = TimeSignatures.SimpleQuadruple }; + /// + /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. + /// + private const double default_beat_length = 60000.0 / 60.0; + + public static readonly TimingControlPoint DEFAULT = new TimingControlPoint + { + BeatLengthBindable = + { + Value = default_beat_length, + Disabled = true + }, + TimeSignatureBindable = { Disabled = true } + }; + /// /// The time signature at this control point. /// diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index be5cd78dc8..b30ec0ca2c 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -369,7 +369,9 @@ namespace osu.Game.Beatmaps.Formats addControlPoint(time, controlPoint, true); } - addControlPoint(time, new LegacyDifficultyControlPoint +#pragma warning disable 618 + addControlPoint(time, new LegacyDifficultyControlPoint(beatLength) +#pragma warning restore 618 { SpeedMultiplier = speedMultiplier, }, timingChange); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index cefb47893c..57555cce90 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -218,7 +218,7 @@ namespace osu.Game.Beatmaps.Formats break; case 2: - position.X = ((IHasXPosition)hitObject).X * 512; + position.X = ((IHasXPosition)hitObject).X; break; case 3: diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index a0e83554a3..44ef9bcacc 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -159,11 +159,20 @@ namespace osu.Game.Beatmaps.Formats Mania, } - internal class LegacyDifficultyControlPoint : DifficultyControlPoint + [Obsolete("Do not use unless you're a legacy ruleset and 100% sure.")] + public class LegacyDifficultyControlPoint : DifficultyControlPoint { - public LegacyDifficultyControlPoint() + /// + /// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it. + /// DO NOT USE THIS UNLESS 100% SURE. + /// + public readonly float BpmMultiplier; + + public LegacyDifficultyControlPoint(double beatLength) { SpeedMultiplierBindable.Precision = double.Epsilon; + + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100f : 1; } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 9d31bc9bba..a8a8794320 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -91,6 +91,7 @@ namespace osu.Game.Configuration Set(OsuSetting.FadePlayfieldWhenHealthLow, true); Set(OsuSetting.KeyOverlay, false); Set(OsuSetting.PositionalHitSounds, true); + Set(OsuSetting.AlwaysPlayFirstComboBreak, true); Set(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth); Set(OsuSetting.FloatingComments, false); @@ -98,6 +99,7 @@ namespace osu.Game.Configuration Set(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised); Set(OsuSetting.IncreaseFirstObjectVisibility, true); + Set(OsuSetting.GameplayDisableWinKey, true); // Update Set(OsuSetting.ReleaseStream, ReleaseStream.Lazer); @@ -180,6 +182,7 @@ namespace osu.Game.Configuration ShowStoryboard, KeyOverlay, PositionalHitSounds, + AlwaysPlayFirstComboBreak, ScoreMeter, FloatingComments, ShowInterface, @@ -227,6 +230,7 @@ namespace osu.Game.Configuration IntroSequence, UIHoldActivationDelay, HitLighting, - MenuBackgroundSource + MenuBackgroundSource, + GameplayDisableWinKey } } diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index dd5c41285a..df063f57d5 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -43,14 +43,6 @@ namespace osu.Game.Graphics.Containers /// public double MinimumBeatLength { get; set; } - /// - /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. - /// - private const double default_beat_length = 60000.0 / 60.0; - - private TimingControlPoint defaultTiming; - private EffectControlPoint defaultEffect; - protected bool IsBeatSyncedWithTrack { get; private set; } protected override void Update() @@ -81,8 +73,8 @@ namespace osu.Game.Graphics.Containers if (timingPoint == null || !IsBeatSyncedWithTrack) { currentTrackTime = Clock.CurrentTime; - timingPoint = defaultTiming; - effectPoint = defaultEffect; + timingPoint = TimingControlPoint.DEFAULT; + effectPoint = EffectControlPoint.DEFAULT; } double beatLength = timingPoint.BeatLength / Divisor; @@ -116,17 +108,6 @@ namespace osu.Game.Graphics.Containers private void load(IBindable beatmap) { Beatmap.BindTo(beatmap); - - defaultTiming = new TimingControlPoint - { - BeatLength = default_beat_length, - }; - - defaultEffect = new EffectControlPoint - { - KiaiMode = false, - OmitFirstBarLine = false - }; } protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index b7ea1ba56a..3015c44613 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.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.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -55,7 +54,16 @@ namespace osu.Game.Graphics.Cursor return; } - var newTarget = inputManager.HoveredDrawables.OfType().FirstOrDefault(t => t.ProvidingUserCursor) ?? this; + IProvideCursor newTarget = this; + + foreach (var d in inputManager.HoveredDrawables) + { + if (d is IProvideCursor p && p.ProvidingUserCursor) + { + newTarget = p; + break; + } + } if (currentTarget == newTarget) return; diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs new file mode 100644 index 0000000000..67fcab43f7 --- /dev/null +++ b/osu.Game/Graphics/DateTooltip.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics +{ + public class DateTooltip : VisibilityContainer, ITooltip + { + private readonly OsuSpriteText dateText, timeText; + private readonly Box background; + + public DateTooltip() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + dateText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + timeText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } + } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.GreySeafoamDarker; + timeText.Colour = colours.BlueLighter; + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + public bool SetContent(object content) + { + if (!(content is DateTimeOffset date)) + return false; + + dateText.Text = $"{date:d MMMM yyyy} "; + timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; + return true; + } + + public void Move(Vector2 pos) => Position = pos; + } +} diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 8b6df4a834..259d9c8d6e 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -4,12 +4,9 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Sprites; using osu.Game.Utils; -using osuTK; namespace osu.Game.Graphics { @@ -81,69 +78,5 @@ namespace osu.Game.Graphics public ITooltip GetCustomTooltip() => new DateTooltip(); public object TooltipContent => Date; - - private class DateTooltip : VisibilityContainer, ITooltip - { - private readonly OsuSpriteText dateText, timeText; - private readonly Box background; - - public DateTooltip() - { - AutoSizeAxes = Axes.Both; - Masking = true; - CornerRadius = 5; - - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Padding = new MarginPadding(10), - Children = new Drawable[] - { - dateText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - }, - timeText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - } - } - }, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - background.Colour = colours.GreySeafoamDarker; - timeText.Colour = colours.BlueLighter; - } - - protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); - protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); - - public bool SetContent(object content) - { - if (!(content is DateTimeOffset date)) - return false; - - dateText.Text = $"{date:d MMMM yyyy} "; - timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; - return true; - } - - public void Move(Vector2 pos) => Position = pos; - } } } diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 9804aefce8..d1f6fd445e 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -19,6 +19,7 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; namespace osu.Game.Graphics { @@ -119,7 +120,9 @@ namespace osu.Game.Graphics break; case ScreenshotFormat.Jpg: - image.SaveAsJpeg(stream); + const int jpeg_quality = 92; + + image.SaveAsJpeg(stream, new JpegEncoder { Quality = jpeg_quality }); break; default: diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs new file mode 100644 index 0000000000..b1383065fe --- /dev/null +++ b/osu.Game/Graphics/Sprites/LogoAnimation.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Graphics.Sprites +{ + public class LogoAnimation : Sprite + { + [BackgroundDependencyLoader] + private void load(ShaderManager shaders, TextureStore textures) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); + RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); // Masking isn't supported for now + } + + private float animationProgress; + + public float AnimationProgress + { + get => animationProgress; + set + { + if (animationProgress == value) return; + + animationProgress = value; + Invalidate(Invalidation.DrawInfo); + } + } + + public override bool IsPresent => true; + + protected override DrawNode CreateDrawNode() => new LogoAnimationDrawNode(this); + + private class LogoAnimationDrawNode : SpriteDrawNode + { + private LogoAnimation source => (LogoAnimation)Source; + + private float progress; + + public LogoAnimationDrawNode(LogoAnimation source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + progress = source.animationProgress; + } + + protected override void Blit(Action vertexAction) + { + Shader.GetUniform("progress").UpdateValue(ref progress); + + base.Blit(vertexAction); + } + + protected override bool CanDrawOpaqueInterior => false; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs index 84429bf5bd..fb5ff4aad3 100644 --- a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs @@ -27,6 +27,8 @@ namespace osu.Game.Graphics.UserInterface { Height = 32; TabContainer.Spacing = new Vector2(padding, 0f); + SwitchTabOnRemove = false; + Current.ValueChanged += index => { foreach (var t in TabContainer.Children.OfType()) diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index 86a5cb9aa6..da6c95299e 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -63,16 +63,19 @@ namespace osu.Game.Graphics.UserInterface background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); + TooltipText = "Download"; break; case DownloadState.Downloading: background.FadeColour(colours.Blue, 500, Easing.InOutExpo); icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); + TooltipText = "Downloading..."; break; case DownloadState.Downloaded: background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); + TooltipText = "Importing"; break; case DownloadState.LocallyAvailable: diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index 8977f014b6..f77a3109c9 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -67,6 +67,8 @@ namespace osu.Game.Graphics.UserInterface public bool OnPressed(GlobalAction action) { + if (!HasFocus) return false; + if (action == GlobalAction.Back) { if (Text.Length > 0) diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 0c82a869f8..ac6f5ceb1b 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -24,6 +24,8 @@ namespace osu.Game.Graphics.UserInterface Child = new PasswordMaskChar(CalculatedTextSize), }; + protected override bool AllowUniqueCharacterSamples => false; + protected override bool AllowClipboardExport => false; private readonly CapsWarning warning; diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index c2feca171b..61501b0cd8 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs @@ -23,6 +23,8 @@ namespace osu.Game.Graphics.UserInterface { private Color4 accentColour; + public const float HORIZONTAL_SPACING = 10; + public virtual Color4 AccentColour { get => accentColour; @@ -54,7 +56,7 @@ namespace osu.Game.Graphics.UserInterface public OsuTabControl() { - TabContainer.Spacing = new Vector2(10f, 0f); + TabContainer.Spacing = new Vector2(HORIZONTAL_SPACING, 0f); AddInternal(strip = new Box { diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index 544acc7eb2..bdc95ee048 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -21,7 +21,6 @@ namespace osu.Game.Graphics.UserInterface { private readonly Box box; private readonly SpriteText text; - private readonly SpriteIcon icon; private Color4? accentColour; @@ -32,12 +31,6 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; - if (Current.Value) - { - text.Colour = AccentColour; - icon.Colour = AccentColour; - } - updateFade(); } } @@ -52,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface public OsuTabControlCheckbox() { + SpriteIcon icon; + AutoSizeAxes = Axes.Both; Children = new Drawable[] @@ -89,6 +84,8 @@ namespace osu.Game.Graphics.UserInterface { icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle; text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium); + + updateFade(); }; } @@ -115,8 +112,8 @@ namespace osu.Game.Graphics.UserInterface private void updateFade() { - box.FadeTo(IsHovered ? 1 : 0, transition_length, Easing.OutQuint); - text.FadeColour(IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); + box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint); + text.FadeColour(Current.Value || IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 06c46fbb91..0d173e2d3e 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -11,6 +14,7 @@ using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osuTK; @@ -19,6 +23,18 @@ namespace osu.Game.Graphics.UserInterface { public class OsuTextBox : BasicTextBox { + private readonly SampleChannel[] textAddedSamples = new SampleChannel[4]; + private SampleChannel capsTextAddedSample; + private SampleChannel textRemovedSample; + private SampleChannel textCommittedSample; + private SampleChannel caretMovedSample; + + /// + /// Whether to allow playing a different samples based on the type of character. + /// If set to false, the same sample will be used for all characters. + /// + protected virtual bool AllowUniqueCharacterSamples => true; + protected override float LeftRightPadding => 10; protected override float CaretWidth => 3; @@ -41,15 +57,54 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colour) + private void load(OsuColour colour, AudioManager audio) { BackgroundUnfocused = Color4.Black.Opacity(0.5f); BackgroundFocused = OsuColour.Gray(0.3f).Opacity(0.8f); BackgroundCommit = BorderColour = colour.Yellow; + + for (int i = 0; i < textAddedSamples.Length; i++) + textAddedSamples[i] = audio.Samples.Get($@"Keyboard/key-press-{1 + i}"); + + capsTextAddedSample = audio.Samples.Get(@"Keyboard/key-caps"); + textRemovedSample = audio.Samples.Get(@"Keyboard/key-delete"); + textCommittedSample = audio.Samples.Get(@"Keyboard/key-confirm"); + caretMovedSample = audio.Samples.Get(@"Keyboard/key-movement"); } protected override Color4 SelectionColour => new Color4(249, 90, 255, 255); + protected override void OnTextAdded(string added) + { + base.OnTextAdded(added); + + if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) + capsTextAddedSample?.Play(); + else + textAddedSamples[RNG.Next(0, 3)]?.Play(); + } + + protected override void OnTextRemoved(string removed) + { + base.OnTextRemoved(removed); + + textRemovedSample?.Play(); + } + + protected override void OnTextCommitted(bool textChanged) + { + base.OnTextCommitted(textChanged); + + textCommittedSample?.Play(); + } + + protected override void OnCaretMoved(bool selecting) + { + base.OnCaretMoved(selecting); + + caretMovedSample?.Play(); + } + protected override void OnFocus(FocusEvent e) { BorderThickness = 3; diff --git a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs index c9cd9f1158..924c7913f3 100644 --- a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osuTK; -using osuTK.Graphics; using System.Collections.Generic; namespace osu.Game.Graphics.UserInterface @@ -16,14 +18,6 @@ namespace osu.Game.Graphics.UserInterface { private const int duration = 200; - private Color4 chevronIconColour; - - protected Color4 ChevronIconColour - { - get => chevronIconColour; - set => chevronIconColour = leftChevron.Colour = rightChevron.Colour = value; - } - public string Text { get => text.Text; @@ -32,22 +26,28 @@ namespace osu.Game.Graphics.UserInterface protected override IEnumerable EffectTargets => new[] { background }; - private ChevronIcon leftChevron; - private ChevronIcon rightChevron; + private ChevronIcon leftIcon; + private ChevronIcon rightIcon; private SpriteText text; private Box background; private FillFlowContainer textContainer; public ShowMoreButton() { - Height = 30; - Width = 140; + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; } protected override Drawable CreateContent() => new CircularContainer { Masking = true, - RelativeSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Both, Children = new Drawable[] { background = new Box @@ -56,22 +56,36 @@ namespace osu.Game.Graphics.UserInterface }, textContainer = new FillFlowContainer { + AlwaysPresent = true, Anchor = Anchor.Centre, Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(7), + Spacing = new Vector2(10), + Margin = new MarginPadding + { + Horizontal = 20, + Vertical = 5 + }, Children = new Drawable[] { - leftChevron = new ChevronIcon(), + leftIcon = new ChevronIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, text = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = "show more".ToUpper(), }, - rightChevron = new ChevronIcon(), + rightIcon = new ChevronIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } } } } @@ -81,17 +95,40 @@ namespace osu.Game.Graphics.UserInterface protected override void OnLoadFinished() => textContainer.FadeIn(duration, Easing.OutQuint); - private class ChevronIcon : SpriteIcon + protected override bool OnHover(HoverEvent e) { - private const int icon_size = 8; + base.OnHover(e); + leftIcon.SetHoveredState(true); + rightIcon.SetHoveredState(true); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + leftIcon.SetHoveredState(false); + rightIcon.SetHoveredState(false); + } + + public class ChevronIcon : SpriteIcon + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } public ChevronIcon() { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - Size = new Vector2(icon_size); + Size = new Vector2(7.5f); Icon = FontAwesome.Solid.ChevronDown; } + + [BackgroundDependencyLoader] + private void load() + { + Colour = colourProvider.Foreground1; + } + + public void SetHoveredState(bool hovered) => + this.FadeColour(hovered ? colourProvider.Light1 : colourProvider.Foreground1, 200, Easing.OutQuint); } } } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 7104031b56..8097f61ea4 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Configuration; @@ -9,7 +12,26 @@ namespace osu.Game.IO { public class OsuStorage : MigratableStorage { + /// + /// Indicates the error (if any) that occurred when initialising the custom storage during initial startup. + /// + public readonly OsuStorageError Error; + + /// + /// The custom storage path as selected by the user. + /// + [CanBeNull] + public string CustomStoragePath => storageConfig.Get(StorageConfig.FullPath); + + /// + /// The default storage path to be used if a custom storage path hasn't been selected or is not accessible. + /// + [NotNull] + public string DefaultStoragePath => defaultStorage.GetFullPath("."); + + private readonly GameHost host; private readonly StorageConfigManager storageConfig; + private readonly Storage defaultStorage; public override string[] IgnoreDirectories => new[] { "cache" }; @@ -19,15 +41,57 @@ namespace osu.Game.IO "storage.ini" }; - public OsuStorage(GameHost host) - : base(host.Storage, string.Empty) + public OsuStorage(GameHost host, Storage defaultStorage) + : base(defaultStorage, string.Empty) { - storageConfig = new StorageConfigManager(host.Storage); + this.host = host; + this.defaultStorage = defaultStorage; - var customStoragePath = storageConfig.Get(StorageConfig.FullPath); + storageConfig = new StorageConfigManager(defaultStorage); - if (!string.IsNullOrEmpty(customStoragePath)) - ChangeTargetStorage(host.GetStorage(customStoragePath)); + if (!string.IsNullOrEmpty(CustomStoragePath)) + TryChangeToCustomStorage(out Error); + } + + /// + /// Resets the custom storage path, changing the target storage to the default location. + /// + public void ResetCustomStoragePath() + { + storageConfig.Set(StorageConfig.FullPath, string.Empty); + storageConfig.Save(); + + ChangeTargetStorage(defaultStorage); + } + + /// + /// Attempts to change to the user's custom storage path. + /// + /// The error that occurred. + /// Whether the custom storage path was used successfully. If not, will be populated with the reason. + public bool TryChangeToCustomStorage(out OsuStorageError error) + { + Debug.Assert(!string.IsNullOrEmpty(CustomStoragePath)); + + error = OsuStorageError.None; + Storage lastStorage = UnderlyingStorage; + + try + { + Storage userStorage = host.GetStorage(CustomStoragePath); + + if (!userStorage.ExistsDirectory(".") || !userStorage.GetFiles(".").Any()) + error = OsuStorageError.AccessibleButEmpty; + + ChangeTargetStorage(userStorage); + } + catch + { + error = OsuStorageError.NotAccessible; + ChangeTargetStorage(lastStorage); + } + + return error == OsuStorageError.None; } protected override void ChangeTargetStorage(Storage newStorage) @@ -43,4 +107,23 @@ namespace osu.Game.IO storageConfig.Save(); } } + + public enum OsuStorageError + { + /// + /// No error. + /// + None, + + /// + /// Occurs when the target storage directory is accessible but does not already contain game files. + /// Only happens when the user changes the storage directory and then moves the files manually or mounts a different device to the same path. + /// + AccessibleButEmpty, + + /// + /// Occurs when the target storage directory cannot be accessed at all. + /// + NotAccessible, + } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 618798a6d8..6ae420b162 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -35,6 +35,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleDirect), + new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), new KeyBinding(InputKey.Escape, GlobalAction.Back), new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), @@ -56,6 +57,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Tilde }, GlobalAction.QuickExit), new KeyBinding(new[] { InputKey.Control, InputKey.Plus }, GlobalAction.IncreaseScrollSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Minus }, GlobalAction.DecreaseScrollSpeed), + new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), }; public IEnumerable AudioControlKeyBindings => new[] @@ -157,5 +159,11 @@ namespace osu.Game.Input.Bindings [Description("Home")] Home, + + [Description("Toggle notifications")] + ToggleNotifications, + + [Description("Pause")] + PauseGameplay, } } diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs index 74b3134964..198ab6883d 100644 --- a/osu.Game/Input/KeyBindingStore.cs +++ b/osu.Game/Input/KeyBindingStore.cs @@ -55,6 +55,9 @@ namespace osu.Game.Input RulesetID = rulesetId, Variant = variant }); + + // required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686) + usage.Context.SaveChanges(); } } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 0f8acbb7af..6912d9b629 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -5,6 +5,7 @@ using System; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Framework.Logging; +using osu.Game.Users; namespace osu.Game.Online.API { @@ -61,6 +62,11 @@ namespace osu.Game.Online.API protected APIAccess API; protected WebRequest WebRequest; + /// + /// The currently logged in user. Note that this will only be populated during . + /// + protected User User { get; private set; } + /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). @@ -86,6 +92,7 @@ namespace osu.Game.Online.API } API = apiAccess; + User = apiAccess.LocalUser.Value; if (checkAndScheduleFailure()) return; @@ -129,6 +136,11 @@ namespace osu.Game.Online.API Success?.Invoke(); } + internal void TriggerFailure(Exception e) + { + Failure?.Invoke(e); + } + public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); public void Fail(Exception e) @@ -159,7 +171,7 @@ namespace osu.Game.Online.API } Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); - pendingFailure = () => Failure?.Invoke(e); + pendingFailure = () => TriggerFailure(e); checkAndScheduleFailure(); } diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs index f21445ca32..3de8db770c 100644 --- a/osu.Game/Online/API/Requests/Cursor.cs +++ b/osu.Game/Online/API/Requests/Cursor.cs @@ -15,6 +15,6 @@ namespace osu.Game.Online.API.Requests { [UsedImplicitly] [JsonExtensionData] - public IDictionary Properties; + public IDictionary Properties { get; set; } = new Dictionary(); } } diff --git a/osu.Game/Online/API/Requests/GetNewsRequest.cs b/osu.Game/Online/API/Requests/GetNewsRequest.cs new file mode 100644 index 0000000000..36d9dc0652 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetNewsRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.IO.Network; +using osu.Game.Extensions; + +namespace osu.Game.Online.API.Requests +{ + public class GetNewsRequest : APIRequest + { + private readonly Cursor cursor; + + public GetNewsRequest(Cursor cursor = null) + { + this.cursor = cursor; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.AddCursor(cursor); + return req; + } + + protected override string Target => "news"; + } +} diff --git a/osu.Game/Online/API/Requests/GetNewsResponse.cs b/osu.Game/Online/API/Requests/GetNewsResponse.cs new file mode 100644 index 0000000000..835289a51d --- /dev/null +++ b/osu.Game/Online/API/Requests/GetNewsResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetNewsResponse : ResponseWithCursor + { + [JsonProperty("news_posts")] + public IEnumerable NewsPosts; + } +} diff --git a/osu.Game/Online/API/Requests/GetRoomPlaylistScoresRequest.cs b/osu.Game/Online/API/Requests/GetRoomPlaylistScoresRequest.cs deleted file mode 100644 index 38f852870b..0000000000 --- a/osu.Game/Online/API/Requests/GetRoomPlaylistScoresRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace osu.Game.Online.API.Requests -{ - public class GetRoomPlaylistScoresRequest : APIRequest - { - private readonly int roomId; - private readonly int playlistItemId; - - public GetRoomPlaylistScoresRequest(int roomId, int playlistItemId) - { - this.roomId = roomId; - this.playlistItemId = playlistItemId; - } - - protected override string Target => $@"rooms/{roomId}/playlist/{playlistItemId}/scores"; - } - - public class RoomPlaylistScores - { - [JsonProperty("scores")] - public List Scores { get; set; } - } -} diff --git a/osu.Game/Online/API/Requests/GetRoomsRequest.cs b/osu.Game/Online/API/Requests/GetRoomsRequest.cs deleted file mode 100644 index 8f1497ef33..0000000000 --- a/osu.Game/Online/API/Requests/GetRoomsRequest.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Game.Online.Multiplayer; -using osu.Game.Screens.Multi.Lounge.Components; - -namespace osu.Game.Online.API.Requests -{ - public class GetRoomsRequest : APIRequest> - { - private readonly PrimaryFilter primaryFilter; - - public GetRoomsRequest(PrimaryFilter primaryFilter) - { - this.primaryFilter = primaryFilter; - } - - protected override string Target - { - get - { - string target = "rooms"; - - switch (primaryFilter) - { - case PrimaryFilter.Open: - break; - - case PrimaryFilter.Owned: - target += "/owned"; - break; - - case PrimaryFilter.Participated: - target += "/participated"; - break; - - case PrimaryFilter.RecentlyEnded: - target += "/ended"; - break; - } - - return target; - } - } - } -} diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs index a279db134f..25e6b3f1af 100644 --- a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.IO.Network; +using osu.Game.Overlays.Rankings; using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests @@ -9,11 +10,13 @@ namespace osu.Game.Online.API.Requests public class GetSpotlightRankingsRequest : GetRankingsRequest { private readonly int spotlight; + private readonly RankingsSortCriteria sort; - public GetSpotlightRankingsRequest(RulesetInfo ruleset, int spotlight) + public GetSpotlightRankingsRequest(RulesetInfo ruleset, int spotlight, RankingsSortCriteria sort) : base(ruleset, 1) { this.spotlight = spotlight; + this.sort = sort; } protected override WebRequest CreateWebRequest() @@ -21,6 +24,7 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); req.AddParameter("spotlight", spotlight.ToString()); + req.AddParameter("filter", sort.ToString().ToLower()); return req; } diff --git a/osu.Game/Online/API/Requests/JoinChannelRequest.cs b/osu.Game/Online/API/Requests/JoinChannelRequest.cs index f6ed5f22c9..33eab7e355 100644 --- a/osu.Game/Online/API/Requests/JoinChannelRequest.cs +++ b/osu.Game/Online/API/Requests/JoinChannelRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class JoinChannelRequest : APIRequest { private readonly Channel channel; - private readonly User user; - public JoinChannelRequest(Channel channel, User user) + public JoinChannelRequest(Channel channel) { this.channel = channel; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{user.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs index f2ae3926bd..7dfc9a0aed 100644 --- a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs +++ b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class LeaveChannelRequest : APIRequest { private readonly Channel channel; - private readonly User user; - public LeaveChannelRequest(Channel channel, User user) + public LeaveChannelRequest(Channel channel) { this.channel = channel; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{user.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/Responses/APINewsPost.cs b/osu.Game/Online/API/Requests/Responses/APINewsPost.cs new file mode 100644 index 0000000000..ced08f0bf2 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINewsPost.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using System; +using System.Net; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APINewsPost + { + [JsonProperty("id")] + public long Id { get; set; } + + private string author; + + [JsonProperty("author")] + public string Author + { + get => author; + set => author = WebUtility.HtmlDecode(value); + } + + [JsonProperty("edit_url")] + public string EditUrl { get; set; } + + [JsonProperty("first_image")] + public string FirstImage { get; set; } + + [JsonProperty("published_at")] + public DateTimeOffset PublishedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + [JsonProperty("slug")] + public string Slug { get; set; } + + private string title; + + [JsonProperty("title")] + public string Title + { + get => title; + set => title = WebUtility.HtmlDecode(value); + } + + private string preview; + + [JsonProperty("preview")] + public string Preview + { + get => preview; + set => preview = WebUtility.HtmlDecode(value); + } + } +} diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 3b336fef4f..f7ed57f207 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -381,7 +381,7 @@ namespace osu.Game.Online.Chat break; default: - var req = new JoinChannelRequest(channel, api.LocalUser.Value); + var req = new JoinChannelRequest(channel); req.Success += () => joinChannel(channel, fetchInitialMessages); req.Failure += ex => LeaveChannel(channel); api.Queue(req); @@ -410,7 +410,7 @@ namespace osu.Game.Online.Chat if (channel.Joined.Value) { - api.Queue(new LeaveChannelRequest(channel, api.LocalUser.Value)); + api.Queue(new LeaveChannelRequest(channel)); channel.Joined.Value = false; } } diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index e2a817aaff..800029ceb9 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -170,36 +170,37 @@ namespace osu.Game.Online.Leaderboards { InternalChildren = new Drawable[] { - new GridContainer + new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Masking = true, + Child = new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - new OsuContextMenuContainer + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Child = scrollContainer = new OsuScrollContainer + scrollContainer = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, } + }, + new Drawable[] + { + content = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }, } }, - new Drawable[] - { - content = new Container - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - }, - } }, }, loading = new LoadingSpinner(), diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 1469f29874..b60d71cfe7 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -58,6 +58,9 @@ namespace osu.Game.Online.Leaderboards [Resolved(CanBeNull = true)] private DialogOverlay dialogOverlay { get; set; } + [Resolved(CanBeNull = true)] + private SongSelect songSelect { get; set; } + public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) { this.score = score; @@ -373,6 +376,9 @@ namespace osu.Game.Online.Leaderboards { List items = new List(); + if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods)); + if (score.ID != 0) items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); diff --git a/osu.Game/Online/API/Requests/Responses/APICreatedRoom.cs b/osu.Game/Online/Multiplayer/APICreatedRoom.cs similarity index 78% rename from osu.Game/Online/API/Requests/Responses/APICreatedRoom.cs rename to osu.Game/Online/Multiplayer/APICreatedRoom.cs index a554101bc7..2a3bb39647 100644 --- a/osu.Game/Online/API/Requests/Responses/APICreatedRoom.cs +++ b/osu.Game/Online/Multiplayer/APICreatedRoom.cs @@ -2,9 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using Newtonsoft.Json; -using osu.Game.Online.Multiplayer; -namespace osu.Game.Online.API.Requests.Responses +namespace osu.Game.Online.Multiplayer { public class APICreatedRoom : Room { diff --git a/osu.Game/Online/API/APIPlaylistBeatmap.cs b/osu.Game/Online/Multiplayer/APIPlaylistBeatmap.cs similarity index 94% rename from osu.Game/Online/API/APIPlaylistBeatmap.cs rename to osu.Game/Online/Multiplayer/APIPlaylistBeatmap.cs index 4f7786e880..98972ef36d 100644 --- a/osu.Game/Online/API/APIPlaylistBeatmap.cs +++ b/osu.Game/Online/Multiplayer/APIPlaylistBeatmap.cs @@ -6,7 +6,7 @@ using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; -namespace osu.Game.Online.API +namespace osu.Game.Online.Multiplayer { public class APIPlaylistBeatmap : APIBeatmap { diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreToken.cs b/osu.Game/Online/Multiplayer/APIScoreToken.cs similarity index 85% rename from osu.Game/Online/API/Requests/Responses/APIScoreToken.cs rename to osu.Game/Online/Multiplayer/APIScoreToken.cs index 1d2465bedf..1f0063d94e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreToken.cs +++ b/osu.Game/Online/Multiplayer/APIScoreToken.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json; -namespace osu.Game.Online.API.Requests.Responses +namespace osu.Game.Online.Multiplayer { public class APIScoreToken { diff --git a/osu.Game/Online/API/Requests/CreateRoomRequest.cs b/osu.Game/Online/Multiplayer/CreateRoomRequest.cs similarity index 86% rename from osu.Game/Online/API/Requests/CreateRoomRequest.cs rename to osu.Game/Online/Multiplayer/CreateRoomRequest.cs index c848c55cc6..dcb4ed51ea 100644 --- a/osu.Game/Online/API/Requests/CreateRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/CreateRoomRequest.cs @@ -4,10 +4,9 @@ using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Multiplayer; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class CreateRoomRequest : APIRequest { diff --git a/osu.Game/Online/API/Requests/CreateRoomScoreRequest.cs b/osu.Game/Online/Multiplayer/CreateRoomScoreRequest.cs similarity index 77% rename from osu.Game/Online/API/Requests/CreateRoomScoreRequest.cs rename to osu.Game/Online/Multiplayer/CreateRoomScoreRequest.cs index e6246b4f1f..2d99b12519 100644 --- a/osu.Game/Online/API/Requests/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Multiplayer/CreateRoomScoreRequest.cs @@ -3,25 +3,28 @@ using System.Net.Http; using osu.Framework.IO.Network; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class CreateRoomScoreRequest : APIRequest { private readonly int roomId; private readonly int playlistItemId; + private readonly string versionHash; - public CreateRoomScoreRequest(int roomId, int playlistItemId) + public CreateRoomScoreRequest(int roomId, int playlistItemId, string versionHash) { this.roomId = roomId; this.playlistItemId = playlistItemId; + this.versionHash = versionHash; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); req.Method = HttpMethod.Post; + req.AddParameter("version_hash", versionHash); return req; } diff --git a/osu.Game/Online/API/Requests/GetRoomRequest.cs b/osu.Game/Online/Multiplayer/GetRoomRequest.cs similarity index 84% rename from osu.Game/Online/API/Requests/GetRoomRequest.cs rename to osu.Game/Online/Multiplayer/GetRoomRequest.cs index 531e1857de..2907b49f1d 100644 --- a/osu.Game/Online/API/Requests/GetRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/GetRoomRequest.cs @@ -1,9 +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.Online.Multiplayer; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class GetRoomRequest : APIRequest { diff --git a/osu.Game/Online/API/Requests/GetRoomScoresRequest.cs b/osu.Game/Online/Multiplayer/GetRoomScoresRequest.cs similarity index 89% rename from osu.Game/Online/API/Requests/GetRoomScoresRequest.cs rename to osu.Game/Online/Multiplayer/GetRoomScoresRequest.cs index eb53369d18..bc913030dd 100644 --- a/osu.Game/Online/API/Requests/GetRoomScoresRequest.cs +++ b/osu.Game/Online/Multiplayer/GetRoomScoresRequest.cs @@ -2,9 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class GetRoomScoresRequest : APIRequest> { diff --git a/osu.Game/Online/Multiplayer/GetRoomsRequest.cs b/osu.Game/Online/Multiplayer/GetRoomsRequest.cs new file mode 100644 index 0000000000..64e0386f77 --- /dev/null +++ b/osu.Game/Online/Multiplayer/GetRoomsRequest.cs @@ -0,0 +1,38 @@ +// 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 Humanizer; +using osu.Framework.IO.Network; +using osu.Game.Online.API; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Online.Multiplayer +{ + public class GetRoomsRequest : APIRequest> + { + private readonly RoomStatusFilter statusFilter; + private readonly RoomCategoryFilter categoryFilter; + + public GetRoomsRequest(RoomStatusFilter statusFilter, RoomCategoryFilter categoryFilter) + { + this.statusFilter = statusFilter; + this.categoryFilter = categoryFilter; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (statusFilter != RoomStatusFilter.Open) + req.AddParameter("mode", statusFilter.ToString().Underscore().ToLowerInvariant()); + + if (categoryFilter != RoomCategoryFilter.Any) + req.AddParameter("category", categoryFilter.ToString().Underscore().ToLowerInvariant()); + + return req; + } + + protected override string Target => "rooms"; + } +} diff --git a/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs b/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs new file mode 100644 index 0000000000..684d0aecd8 --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using JetBrains.Annotations; +using osu.Framework.IO.Network; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// Returns a list of scores for the specified playlist item. + /// + public class IndexPlaylistScoresRequest : APIRequest + { + public readonly int RoomId; + public readonly int PlaylistItemId; + + [CanBeNull] + public readonly Cursor Cursor; + + [CanBeNull] + public readonly IndexScoresParams IndexParams; + + public IndexPlaylistScoresRequest(int roomId, int playlistItemId) + { + RoomId = roomId; + PlaylistItemId = playlistItemId; + } + + public IndexPlaylistScoresRequest(int roomId, int playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) + : this(roomId, playlistItemId) + { + Cursor = cursor; + IndexParams = indexParams; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (Cursor != null) + { + Debug.Assert(IndexParams != null); + + req.AddCursor(Cursor); + + foreach (var (key, value) in IndexParams.Properties) + req.AddParameter(key, value.ToString()); + } + + return req; + } + + protected override string Target => $@"rooms/{RoomId}/playlist/{PlaylistItemId}/scores"; + } +} diff --git a/osu.Game/Online/Multiplayer/IndexScoresParams.cs b/osu.Game/Online/Multiplayer/IndexScoresParams.cs new file mode 100644 index 0000000000..a511e9a780 --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexScoresParams.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A collection of parameters which should be passed to the index endpoint to fetch the next page. + /// + public class IndexScoresParams + { + [UsedImplicitly] + [JsonExtensionData] + public IDictionary Properties { get; set; } = new Dictionary(); + } +} diff --git a/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs b/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs new file mode 100644 index 0000000000..e237b7e3fb --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A object returned via a . + /// + public class IndexedMultiplayerScores : MultiplayerScores + { + /// + /// The total scores in the playlist item. + /// + [JsonProperty("total")] + public int? TotalScores { get; set; } + + /// + /// The user's score, if any. + /// + [JsonProperty("user_score")] + [CanBeNull] + public MultiplayerScore UserScore { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/JoinRoomRequest.cs b/osu.Game/Online/Multiplayer/JoinRoomRequest.cs similarity index 71% rename from osu.Game/Online/API/Requests/JoinRoomRequest.cs rename to osu.Game/Online/Multiplayer/JoinRoomRequest.cs index 36b275236c..74375af856 100644 --- a/osu.Game/Online/API/Requests/JoinRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/JoinRoomRequest.cs @@ -3,20 +3,17 @@ using System.Net.Http; using osu.Framework.IO.Network; -using osu.Game.Online.Multiplayer; -using osu.Game.Users; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class JoinRoomRequest : APIRequest { private readonly Room room; - private readonly User user; - public JoinRoomRequest(Room room, User user) + public JoinRoomRequest(Room room) { this.room = room; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{user.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/RoomScore.cs b/osu.Game/Online/Multiplayer/MultiplayerScore.cs similarity index 78% rename from osu.Game/Online/API/RoomScore.cs rename to osu.Game/Online/Multiplayer/MultiplayerScore.cs index 3c7f8c9833..8191003aad 100644 --- a/osu.Game/Online/API/RoomScore.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerScore.cs @@ -4,17 +4,18 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using osu.Game.Online.Multiplayer; +using osu.Game.Online.API; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Users; -namespace osu.Game.Online.API +namespace osu.Game.Online.Multiplayer { - public class RoomScore + public class MultiplayerScore { [JsonProperty("id")] public int ID { get; set; } @@ -47,6 +48,19 @@ namespace osu.Game.Online.API [JsonProperty("ended_at")] public DateTimeOffset EndedAt { get; set; } + /// + /// The position of this score, starting at 1. + /// + [JsonProperty("position")] + public int? Position { get; set; } + + /// + /// Any scores in the room around this score. + /// + [JsonProperty("scores_around")] + [CanBeNull] + public MultiplayerScoresAround ScoresAround { get; set; } + public ScoreInfo CreateScoreInfo(PlaylistItem playlistItem) { var rulesetInstance = playlistItem.Ruleset.Value.CreateInstance(); @@ -66,7 +80,8 @@ namespace osu.Game.Online.API Date = EndedAt, Hash = string.Empty, // todo: temporary? Rank = Rank, - Mods = Mods?.Select(m => m.ToMod(rulesetInstance)).ToArray() ?? Array.Empty() + Mods = Mods?.Select(m => m.ToMod(rulesetInstance)).ToArray() ?? Array.Empty(), + Position = Position, }; return scoreInfo; diff --git a/osu.Game/Online/Multiplayer/MultiplayerScores.cs b/osu.Game/Online/Multiplayer/MultiplayerScores.cs new file mode 100644 index 0000000000..7b9dcff828 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScores.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// An object which contains scores and related data for fetching next pages. + /// + public class MultiplayerScores : ResponseWithCursor + { + /// + /// The scores. + /// + [JsonProperty("scores")] + public List Scores { get; set; } = new List(); + + /// + /// The parameters to be used to fetch the next page. + /// + [JsonProperty("params")] + public IndexScoresParams Params { get; set; } = new IndexScoresParams(); + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs b/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs new file mode 100644 index 0000000000..2ac62d0300 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// An object which stores scores higher and lower than the user's score. + /// + public class MultiplayerScoresAround + { + /// + /// Scores sorted "higher" than the user's score, depending on the sorting order. + /// + [JsonProperty("higher")] + [CanBeNull] + public MultiplayerScores Higher { get; set; } + + /// + /// Scores sorted "lower" than the user's score, depending on the sorting order. + /// + [JsonProperty("lower")] + [CanBeNull] + public MultiplayerScores Lower { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/PartRoomRequest.cs b/osu.Game/Online/Multiplayer/PartRoomRequest.cs similarity index 71% rename from osu.Game/Online/API/Requests/PartRoomRequest.cs rename to osu.Game/Online/Multiplayer/PartRoomRequest.cs index e1550cb2e0..54bb005d96 100644 --- a/osu.Game/Online/API/Requests/PartRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/PartRoomRequest.cs @@ -3,20 +3,17 @@ using System.Net.Http; using osu.Framework.IO.Network; -using osu.Game.Online.Multiplayer; -using osu.Game.Users; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class PartRoomRequest : APIRequest { private readonly Room room; - private readonly User user; - public PartRoomRequest(Room room, User user) + public PartRoomRequest(Room room) { this.room = room; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{user.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/Multiplayer/Room.cs b/osu.Game/Online/Multiplayer/Room.cs index d074ac9775..34cf158442 100644 --- a/osu.Game/Online/Multiplayer/Room.cs +++ b/osu.Game/Online/Multiplayer/Room.cs @@ -16,54 +16,58 @@ namespace osu.Game.Online.Multiplayer { [Cached] [JsonProperty("id")] - public Bindable RoomID { get; private set; } = new Bindable(); + public readonly Bindable RoomID = new Bindable(); [Cached] [JsonProperty("name")] - public Bindable Name { get; private set; } = new Bindable(); + public readonly Bindable Name = new Bindable(); [Cached] [JsonProperty("host")] - public Bindable Host { get; private set; } = new Bindable(); + public readonly Bindable Host = new Bindable(); [Cached] [JsonProperty("playlist")] - public BindableList Playlist { get; private set; } = new BindableList(); + public readonly BindableList Playlist = new BindableList(); [Cached] [JsonProperty("channel_id")] - public Bindable ChannelId { get; private set; } = new Bindable(); + public readonly Bindable ChannelId = new Bindable(); + + [Cached] + [JsonProperty("category")] + public readonly Bindable Category = new Bindable(); [Cached] [JsonIgnore] - public Bindable Duration { get; private set; } = new Bindable(TimeSpan.FromMinutes(30)); + public readonly Bindable Duration = new Bindable(TimeSpan.FromMinutes(30)); [Cached] [JsonIgnore] - public Bindable MaxAttempts { get; private set; } = new Bindable(); + public readonly Bindable MaxAttempts = new Bindable(); [Cached] [JsonIgnore] - public Bindable Status { get; private set; } = new Bindable(new RoomStatusOpen()); + public readonly Bindable Status = new Bindable(new RoomStatusOpen()); [Cached] [JsonIgnore] - public Bindable Availability { get; private set; } = new Bindable(); + public readonly Bindable Availability = new Bindable(); [Cached] [JsonIgnore] - public Bindable Type { get; private set; } = new Bindable(new GameTypeTimeshift()); + public readonly Bindable Type = new Bindable(new GameTypeTimeshift()); [Cached] [JsonIgnore] - public Bindable MaxParticipants { get; private set; } = new Bindable(); + public readonly Bindable MaxParticipants = new Bindable(); [Cached] [JsonProperty("recent_participants")] - public BindableList RecentParticipants { get; private set; } = new BindableList(); + public readonly BindableList RecentParticipants = new BindableList(); [Cached] - public Bindable ParticipantCount { get; private set; } = new Bindable(); + public readonly Bindable ParticipantCount = new Bindable(); // todo: TEMPORARY [JsonProperty("participant_count")] @@ -83,7 +87,7 @@ namespace osu.Game.Online.Multiplayer // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] - public Bindable EndDate { get; private set; } = new Bindable(); + public readonly Bindable EndDate = new Bindable(); // Todo: Find a better way to do this (https://github.com/ppy/osu-framework/issues/1930) [JsonProperty("max_attempts", DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -97,7 +101,7 @@ namespace osu.Game.Online.Multiplayer /// The position of this in the list. This is not read from or written to the API. /// [JsonIgnore] - public Bindable Position { get; private set; } = new Bindable(-1); + public readonly Bindable Position = new Bindable(-1); public void CopyFrom(Room other) { @@ -130,7 +134,7 @@ namespace osu.Game.Online.Multiplayer RecentParticipants.AddRange(other.RecentParticipants); } - Position = other.Position; + Position.Value = other.Position.Value; } public bool ShouldSerializeRoomID() => false; diff --git a/osu.Game/Skinning/GlobalSkinConfiguration.cs b/osu.Game/Online/Multiplayer/RoomCategory.cs similarity index 57% rename from osu.Game/Skinning/GlobalSkinConfiguration.cs rename to osu.Game/Online/Multiplayer/RoomCategory.cs index d405702ea5..636a73a3e9 100644 --- a/osu.Game/Skinning/GlobalSkinConfiguration.cs +++ b/osu.Game/Online/Multiplayer/RoomCategory.cs @@ -1,11 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Skinning +namespace osu.Game.Online.Multiplayer { - public enum GlobalSkinConfiguration + public enum RoomCategory { - AnimationFramerate, - LayeredHitSounds, + Normal, + Spotlight } } diff --git a/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs b/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs new file mode 100644 index 0000000000..936b8bbe89 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Multiplayer +{ + public class ShowPlaylistUserScoreRequest : APIRequest + { + private readonly int roomId; + private readonly int playlistItemId; + private readonly long userId; + + public ShowPlaylistUserScoreRequest(int roomId, int playlistItemId, long userId) + { + this.roomId = roomId; + this.playlistItemId = playlistItemId; + this.userId = userId; + } + + protected override string Target => $"rooms/{roomId}/playlist/{playlistItemId}/scores/users/{userId}"; + } +} diff --git a/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs b/osu.Game/Online/Multiplayer/SubmitRoomScoreRequest.cs similarity index 90% rename from osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs rename to osu.Game/Online/Multiplayer/SubmitRoomScoreRequest.cs index 8eb2952159..d31aef2ea5 100644 --- a/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Multiplayer/SubmitRoomScoreRequest.cs @@ -4,11 +4,12 @@ using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; +using osu.Game.Online.API; using osu.Game.Scoring; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { - public class SubmitRoomScoreRequest : APIRequest + public class SubmitRoomScoreRequest : APIRequest { private readonly int scoreId; private readonly int roomId; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index b0d7b14d34..b5752214bd 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -18,6 +18,7 @@ using osu.Game.Screens.Menu; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Humanizer; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -63,14 +64,15 @@ namespace osu.Game private ChannelManager channelManager; - private NotificationOverlay notifications; - - private NowPlayingOverlay nowPlaying; + [NotNull] + private readonly NotificationOverlay notifications = new NotificationOverlay(); private BeatmapListingOverlay beatmapListing; private DashboardOverlay dashboard; + private NewsOverlay news; + private UserProfileOverlay userProfile; private BeatmapSetOverlay beatmapSetOverlay; @@ -82,7 +84,7 @@ namespace osu.Game public virtual Storage GetStorageForStableInstall() => null; - public float ToolbarOffset => Toolbar.Position.Y + Toolbar.DrawHeight; + public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); private IdleTracker idleTracker; @@ -250,7 +252,7 @@ namespace osu.Game case LinkAction.OpenEditorTimestamp: case LinkAction.JoinMultiplayerMatch: case LinkAction.Spectate: - waitForReady(() => notifications, _ => notifications?.Post(new SimpleNotification + waitForReady(() => notifications, _ => notifications.Post(new SimpleNotification { Text = @"This link type is not yet supported!", Icon = FontAwesome.Solid.LifeRing, @@ -536,14 +538,14 @@ namespace osu.Game MenuCursorContainer.CanShowCursor = menuScreen?.CursorVisible ?? false; // todo: all archive managers should be able to be looped here. - SkinManager.PostNotification = n => notifications?.Post(n); + SkinManager.PostNotification = n => notifications.Post(n); SkinManager.GetStableStorage = GetStorageForStableInstall; - BeatmapManager.PostNotification = n => notifications?.Post(n); + BeatmapManager.PostNotification = n => notifications.Post(n); BeatmapManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); - ScoreManager.PostNotification = n => notifications?.Post(n); + ScoreManager.PostNotification = n => notifications.Post(n); ScoreManager.GetStableStorage = GetStorageForStableInstall; ScoreManager.PresentImport = items => PresentScore(items.First()); @@ -573,7 +575,9 @@ namespace osu.Game Origin = Anchor.BottomLeft, Action = () => { - if ((ScreenStack.CurrentScreen as IOsuScreen)?.AllowBackButton == true) + var currentScreen = ScreenStack.CurrentScreen as IOsuScreen; + + if (currentScreen?.AllowBackButton == true && !currentScreen.OnBackButton()) ScreenStack.Exit(); } }, @@ -613,12 +617,12 @@ namespace osu.Game loadComponentSingleFile(MusicController = new MusicController(), Add, true); - loadComponentSingleFile(notifications = new NotificationOverlay + loadComponentSingleFile(notifications.With(d => { - GetToolbarHeight = () => ToolbarOffset, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, rightFloatingOverlayContent.Add, true); + d.GetToolbarHeight = () => ToolbarOffset; + d.Anchor = Anchor.TopRight; + d.Origin = Anchor.TopRight; + }), rightFloatingOverlayContent.Add, true); loadComponentSingleFile(screenshotManager, Add); @@ -628,6 +632,7 @@ namespace osu.Game // overlay elements loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); + loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); @@ -643,7 +648,7 @@ namespace osu.Game Origin = Anchor.TopRight, }, rightFloatingOverlayContent.Add, true); - loadComponentSingleFile(nowPlaying = new NowPlayingOverlay + loadComponentSingleFile(new NowPlayingOverlay { GetToolbarHeight = () => ToolbarOffset, Anchor = Anchor.TopRight, @@ -685,7 +690,7 @@ namespace osu.Game } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, dashboard, beatmapListing, changelogOverlay, rankingsOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -756,7 +761,7 @@ namespace osu.Game Schedule(() => notifications.Post(new SimpleNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, - Text = entry.Message + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), + Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), })); } else if (recentLogCount == short_term_display_limit) @@ -767,7 +772,7 @@ namespace osu.Game Text = "Subsequent messages have been logged. Click to view log files.", Activated = () => { - Host.Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); + Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); return true; } })); @@ -855,18 +860,6 @@ namespace osu.Game switch (action) { - case GlobalAction.ToggleNowPlaying: - nowPlaying.ToggleVisibility(); - return true; - - case GlobalAction.ToggleChat: - chatOverlay.ToggleVisibility(); - return true; - - case GlobalAction.ToggleSocial: - dashboard.ToggleVisibility(); - return true; - case GlobalAction.ResetInputSettings: var sensitivity = frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity); @@ -882,14 +875,6 @@ namespace osu.Game Toolbar.ToggleVisibility(); return true; - case GlobalAction.ToggleSettings: - Settings.ToggleVisibility(); - return true; - - case GlobalAction.ToggleDirect: - beatmapListing.ToggleVisibility(); - return true; - case GlobalAction.ToggleGameplayMouseButtons: LocalConfig.Set(OsuSetting.MouseDisableButtons, !LocalConfig.Get(OsuSetting.MouseDisableButtons)); return true; diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 97a4e212e8..c5cedd7c1c 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Development; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.IO.Stores; @@ -35,6 +36,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; using osuTK.Input; +using RuntimeInfo = osu.Framework.RuntimeInfo; namespace osu.Game { @@ -97,6 +99,11 @@ namespace osu.Game public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); + /// + /// MD5 representation of the game executable. + /// + public string VersionHash { get; private set; } + public bool IsDeployedBuild => AssemblyVersion.Major > 0; public virtual string Version @@ -128,6 +135,18 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { + try + { + using (var str = File.OpenRead(typeof(OsuGameBase).Assembly.Location)) + VersionHash = str.ComputeMD5Hash(); + } + catch + { + // special case for android builds, which can't read DLLs from a packed apk. + // should eventually be handled in a better way. + VersionHash = $"{Version}-{RuntimeInfo.OS}".ComputeMD5Hash(); + } + Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); @@ -152,6 +171,7 @@ namespace osu.Game AddFont(Resources, @"Fonts/Noto-Hangul"); AddFont(Resources, @"Fonts/Noto-CJK-Basic"); AddFont(Resources, @"Fonts/Noto-CJK-Compatibility"); + AddFont(Resources, @"Fonts/Noto-Thai"); AddFont(Resources, @"Fonts/Venera-Light"); AddFont(Resources, @"Fonts/Venera-Bold"); @@ -198,6 +218,10 @@ namespace osu.Game ScoreManager.Undelete(getBeatmapScores(item), true); }); + var difficultyManager = new BeatmapDifficultyManager(); + dependencies.Cache(difficultyManager); + AddInternal(difficultyManager); + dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore)); dependencies.Cache(SettingsStore = new SettingsStore(contextFactory)); dependencies.Cache(RulesetConfigCache = new RulesetConfigCache(SettingsStore)); @@ -312,11 +336,13 @@ namespace osu.Game base.SetHost(host); // may be non-null for certain tests - Storage ??= new OsuStorage(host); + Storage ??= host.Storage; LocalConfig ??= new OsuConfigManager(Storage); } + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); + private readonly List fileImporters = new List(); public async Task Import(params string[] paths) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs index 64b3afcae1..45ef793deb 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs @@ -91,7 +91,8 @@ namespace osu.Game.Overlays.BeatmapListing [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - ((FilterDropdown)Dropdown).AccentColour = colourProvider.Light2; + if (Dropdown is FilterDropdown fd) + fd.AccentColour = colourProvider.Light2; } protected override Dropdown CreateDropdown() => new FilterDropdown(); diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs index 67782dfe3f..001ca801d9 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -81,7 +81,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels { case DownloadState.LocallyAvailable: button.Enabled.Value = true; - button.TooltipText = string.Empty; + button.TooltipText = "Go to beatmap"; break; default: diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 496986dc56..4eb348ae33 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -114,21 +115,26 @@ namespace osu.Game.Overlays.Chat Colour = Color4.Black.Opacity(0.3f), Type = EdgeEffectType.Shadow, }, - // Drop shadow effect Child = new Container { AutoSizeAxes = Axes.Both, + Y = 3, Masking = true, CornerRadius = 4, - EdgeEffect = new EdgeEffectParameters + Children = new Drawable[] { - Radius = 1, - Colour = Color4Extensions.FromHex(message.Sender.Colour), - Type = EdgeEffectType.Shadow, - }, - Padding = new MarginPadding { Left = 3, Right = 3, Bottom = 1, Top = -3 }, - Y = 3, - Child = username, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(message.Sender.Colour), + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, + Child = username + } + } } }; } diff --git a/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs new file mode 100644 index 0000000000..48f34e8f59 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Framework.Allocation; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class ChevronButton : OsuHoverContainer + { + public readonly BindableBool Expanded = new BindableBool(true); + + private readonly SpriteIcon icon; + + public ChevronButton() + { + Size = new Vector2(40, 22); + Child = icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12), + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = HoverColour = colourProvider.Foreground1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Action = Expanded.Toggle; + Expanded.BindValueChanged(onExpandedChanged, true); + } + + private void onExpandedChanged(ValueChangedEvent expanded) + { + icon.Icon = expanded.NewValue ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs new file mode 100644 index 0000000000..57bf2af4d2 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using static osu.Game.Graphics.UserInterface.ShowMoreButton; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public abstract class CommentRepliesButton : CompositeDrawable + { + protected string Text + { + get => text.Text; + set => text.Text = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly ChevronIcon icon; + private readonly Box background; + private readonly OsuSpriteText text; + + protected CommentRepliesButton() + { + AutoSizeAxes = Axes.Both; + InternalChildren = new Drawable[] + { + new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding + { + Vertical = 5, + Horizontal = 10, + }, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AlwaysPresent = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }, + icon = new ChevronIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + } + } + } + } + } + }, + new HoverClickSounds(), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colourProvider.Background2; + } + + protected void SetIconDirection(bool upwards) => icon.ScaleTo(new Vector2(1, upwards ? -1 : 1)); + + public void ToggleTextVisibility(bool visible) => text.FadeTo(visible ? 1 : 0, 200, Easing.OutQuint); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + background.FadeColour(colourProvider.Background1, 200, Easing.OutQuint); + icon.SetHoveredState(true); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + background.FadeColour(colourProvider.Background2, 200, Easing.OutQuint); + icon.SetHoveredState(false); + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs new file mode 100644 index 0000000000..4998e5391e --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class LoadRepliesButton : LoadingButton + { + private ButtonContent content; + + public LoadRepliesButton() + { + AutoSizeAxes = Axes.Both; + } + + protected override Drawable CreateContent() => content = new ButtonContent(); + + protected override void OnLoadStarted() => content.ToggleTextVisibility(false); + + protected override void OnLoadFinished() => content.ToggleTextVisibility(true); + + private class ButtonContent : CommentRepliesButton + { + public ButtonContent() + { + Text = "load replies"; + } + } + } +} diff --git a/osu.Game/Overlays/Comments/GetCommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs similarity index 68% rename from osu.Game/Overlays/Comments/GetCommentRepliesButton.cs rename to osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs index a3817ba416..c115a8bb8f 100644 --- a/osu.Game/Overlays/Comments/GetCommentRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs @@ -8,38 +8,42 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; using System.Collections.Generic; using osuTK; +using osu.Framework.Allocation; -namespace osu.Game.Overlays.Comments +namespace osu.Game.Overlays.Comments.Buttons { - public abstract class GetCommentRepliesButton : LoadingButton + public class ShowMoreRepliesButton : LoadingButton { - private const int duration = 200; - protected override IEnumerable EffectTargets => new[] { text }; private OsuSpriteText text; - protected GetCommentRepliesButton() + public ShowMoreRepliesButton() { AutoSizeAxes = Axes.Both; LoadingAnimationSize = new Vector2(8); } + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; + } + protected override Drawable CreateContent() => new Container { AutoSizeAxes = Axes.Both, Child = text = new OsuSpriteText { AlwaysPresent = true, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = GetText() + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = "show more" } }; - protected abstract string GetText(); + protected override void OnLoadStarted() => text.FadeOut(200, Easing.OutQuint); - protected override void OnLoadStarted() => text.FadeOut(duration, Easing.OutQuint); - - protected override void OnLoadFinished() => text.FadeIn(duration, Easing.OutQuint); + protected override void OnLoadFinished() => text.FadeIn(200, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs new file mode 100644 index 0000000000..04e7e25cc5 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Humanizer; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class ShowRepliesButton : CommentRepliesButton + { + public readonly BindableBool Expanded = new BindableBool(true); + + public ShowRepliesButton(int count) + { + Text = "reply".ToQuantity(count); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Expanded.BindValueChanged(expanded => SetIconDirection(expanded.NewValue), true); + } + + protected override bool OnClick(ClickEvent e) + { + Expanded.Toggle(); + return true; + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index f71808ba89..2a78748be6 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -78,21 +78,22 @@ namespace osu.Game.Overlays.Comments AutoSizeAxes = Axes.Y, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 - }, new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, + Margin = new MarginPadding { Bottom = 20 }, Children = new Drawable[] { deletedCommentsCounter = new DeletedCommentsCounter { - ShowDeleted = { BindTarget = ShowDeleted } + ShowDeleted = { BindTarget = ShowDeleted }, + Margin = new MarginPadding + { + Horizontal = 70, + Vertical = 10 + } }, new Container { @@ -102,7 +103,10 @@ namespace osu.Game.Overlays.Comments { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Margin = new MarginPadding(5), + Margin = new MarginPadding + { + Vertical = 10 + }, Action = getComments, IsLoading = true, } diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs index 83f44ccd80..0dd68bbd41 100644 --- a/osu.Game/Overlays/Comments/CommentsHeader.cs +++ b/osu.Game/Overlays/Comments/CommentsHeader.cs @@ -86,7 +86,7 @@ namespace osu.Game.Overlays.Comments { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 12), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = @"Show deleted" } }, diff --git a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs index d2ff7ecb1f..adf64eabb1 100644 --- a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs +++ b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; @@ -11,16 +10,6 @@ namespace osu.Game.Overlays.Comments { public readonly BindableInt Current = new BindableInt(); - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - Height = 20; - - IdleColour = colourProvider.Background2; - HoverColour = colourProvider.Background1; - ChevronIconColour = colourProvider.Foreground1; - } - protected override void LoadComplete() { Current.BindValueChanged(onCurrentChanged, true); diff --git a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs index f22086bf23..56588ef0a8 100644 --- a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs +++ b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs @@ -23,8 +23,6 @@ namespace osu.Game.Overlays.Comments public DeletedCommentsCounter() { AutoSizeAxes = Axes.Both; - Margin = new MarginPadding { Vertical = 10, Left = 80 }; - InternalChild = new FillFlowContainer { AutoSizeAxes = Axes.Both, diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 46f600615a..31aa41e967 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -16,19 +16,18 @@ using System.Linq; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osu.Framework.Allocation; -using osuTK.Graphics; using System.Collections.Generic; using System; using osu.Framework.Graphics.Shapes; using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; +using osu.Game.Overlays.Comments.Buttons; namespace osu.Game.Overlays.Comments { public class DrawableComment : CompositeDrawable { private const int avatar_size = 40; - private const int margin = 10; public Action RepliesRequested; @@ -46,9 +45,9 @@ namespace osu.Game.Overlays.Comments private FillFlowContainer childCommentsVisibilityContainer; private FillFlowContainer childCommentsContainer; - private LoadMoreCommentsButton loadMoreCommentsButton; - private ShowMoreButton showMoreButton; - private RepliesButton repliesButton; + private LoadRepliesButton loadRepliesButton; + private ShowMoreRepliesButton showMoreButton; + private ShowRepliesButton showRepliesButton; private ChevronButton chevronButton; private DeletedCommentsCounter deletedCommentsCounter; @@ -58,7 +57,7 @@ namespace osu.Game.Overlays.Comments } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { LinkFlowContainer username; FillFlowContainer info; @@ -70,25 +69,25 @@ namespace osu.Game.Overlays.Comments AutoSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - new FillFlowContainer + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + Padding = getPadding(Comment.IsTopLevel), + Child = new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(margin) { Left = margin + 5 }, - Child = content = new GridContainer + content = new GridContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, ColumnDimensions = new[] { - new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, size: avatar_size + 10), new Dimension(), }, RowDimensions = new[] @@ -99,69 +98,65 @@ namespace osu.Game.Overlays.Comments { new Drawable[] { - new FillFlowContainer + new Container { - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Horizontal = margin }, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), + Size = new Vector2(avatar_size), Children = new Drawable[] { - new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 40, - AutoSizeAxes = Axes.Y, - Child = votePill = new VotePill(Comment) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - } - }, new UpdateableAvatar(Comment.User) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, Size = new Vector2(avatar_size), Masking = true, CornerRadius = avatar_size / 2f, CornerExponent = 2, }, + votePill = new VotePill(Comment) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Margin = new MarginPadding + { + Right = 5 + } + } } }, new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 3), + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 4), + Margin = new MarginPadding + { + Vertical = 2 + }, Children = new Drawable[] { new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(7, 0), + Spacing = new Vector2(10, 0), Children = new Drawable[] { - username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true)) + username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold)) { - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Both }, new ParentUsername(Comment), new OsuSpriteText { Alpha = Comment.IsDeleted ? 1 : 0, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = @"deleted", + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + Text = "deleted" } } }, message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 40 } + AutoSizeAxes = Axes.Y }, info = new FillFlowContainer { @@ -170,19 +165,22 @@ namespace osu.Game.Overlays.Comments Spacing = new Vector2(10, 0), Children = new Drawable[] { - new OsuSpriteText + new DrawableDate(Comment.CreatedAt, 12, false) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 12), - Colour = OsuColour.Gray(0.7f), - Text = HumanizerUtils.Humanize(Comment.CreatedAt) - }, - repliesButton = new RepliesButton(Comment.RepliesCount) + Colour = colourProvider.Foreground1 + } + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + showRepliesButton = new ShowRepliesButton(Comment.RepliesCount) { Expanded = { BindTarget = childrenExpanded } }, - loadMoreCommentsButton = new LoadMoreCommentsButton + loadRepliesButton = new LoadRepliesButton { Action = () => RepliesRequested(this, ++currentPage) } @@ -192,41 +190,51 @@ namespace osu.Game.Overlays.Comments } } } - } - }, - childCommentsVisibilityContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + }, + childCommentsVisibilityContainer = new FillFlowContainer { - childCommentsContainer = new FillFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Left = 20 }, + Children = new Drawable[] { - Padding = new MarginPadding { Left = 20 }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical - }, - deletedCommentsCounter = new DeletedCommentsCounter - { - ShowDeleted = { BindTarget = ShowDeleted } - }, - showMoreButton = new ShowMoreButton - { - Action = () => RepliesRequested(this, ++currentPage) + childCommentsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + }, + deletedCommentsCounter = new DeletedCommentsCounter + { + ShowDeleted = { BindTarget = ShowDeleted }, + Margin = new MarginPadding + { + Top = 10 + } + }, + showMoreButton = new ShowMoreRepliesButton + { + Action = () => RepliesRequested(this, ++currentPage) + } } - } - }, + }, + } } }, - chevronButton = new ChevronButton + new Container { + Size = new Vector2(70, 40), Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Margin = new MarginPadding { Right = 30, Top = margin }, - Expanded = { BindTarget = childrenExpanded }, - Alpha = 0 + Margin = new MarginPadding { Horizontal = 5 }, + Child = chevronButton = new ChevronButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Expanded = { BindTarget = childrenExpanded }, + Alpha = 0 + } } }; @@ -239,10 +247,9 @@ namespace osu.Game.Overlays.Comments { info.Add(new OsuSpriteText { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 12), - Text = $@"edited {HumanizerUtils.Humanize(Comment.EditedAt.Value)} by {Comment.EditedUser.Username}" + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Text = $@"edited {HumanizerUtils.Humanize(Comment.EditedAt.Value)} by {Comment.EditedUser.Username}", + Colour = colourProvider.Foreground1 }); } @@ -339,77 +346,31 @@ namespace osu.Game.Overlays.Comments var loadedReplesCount = loadedReplies.Count; var hasUnloadedReplies = loadedReplesCount != Comment.RepliesCount; - loadMoreCommentsButton.FadeTo(hasUnloadedReplies && loadedReplesCount == 0 ? 1 : 0); + loadRepliesButton.FadeTo(hasUnloadedReplies && loadedReplesCount == 0 ? 1 : 0); showMoreButton.FadeTo(hasUnloadedReplies && loadedReplesCount > 0 ? 1 : 0); - repliesButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + showRepliesButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); if (Comment.IsTopLevel) chevronButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); - showMoreButton.IsLoading = loadMoreCommentsButton.IsLoading = false; + showMoreButton.IsLoading = loadRepliesButton.IsLoading = false; } - private class ChevronButton : ShowChildrenButton + private MarginPadding getPadding(bool isTopLevel) { - private readonly SpriteIcon icon; - - public ChevronButton() + if (isTopLevel) { - Child = icon = new SpriteIcon + return new MarginPadding { - Size = new Vector2(12), + Horizontal = 70, + Vertical = 15 }; } - protected override void OnExpandedChanged(ValueChangedEvent expanded) + return new MarginPadding { - icon.Icon = expanded.NewValue ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; - } - } - - private class RepliesButton : ShowChildrenButton - { - private readonly SpriteText text; - private readonly int count; - - public RepliesButton(int count) - { - this.count = count; - - Child = text = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - }; - } - - protected override void OnExpandedChanged(ValueChangedEvent expanded) - { - text.Text = $@"{(expanded.NewValue ? "[-]" : "[+]")} replies ({count})"; - } - } - - private class LoadMoreCommentsButton : GetCommentRepliesButton - { - public LoadMoreCommentsButton() - { - IdleColour = OsuColour.Gray(0.7f); - HoverColour = Color4.White; - } - - protected override string GetText() => @"[+] load replies"; - } - - private class ShowMoreButton : GetCommentRepliesButton - { - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - Margin = new MarginPadding { Vertical = 10, Left = 80 }; - IdleColour = colourProvider.Light2; - HoverColour = colourProvider.Light1; - } - - protected override string GetText() => @"Show More"; + Top = 10 + }; } private class ParentUsername : FillFlowContainer, IHasTooltip diff --git a/osu.Game/Overlays/Comments/ShowChildrenButton.cs b/osu.Game/Overlays/Comments/ShowChildrenButton.cs deleted file mode 100644 index 5ec7c1d471..0000000000 --- a/osu.Game/Overlays/Comments/ShowChildrenButton.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Game.Graphics.Containers; -using osu.Framework.Bindables; -using osuTK.Graphics; -using osu.Game.Graphics; - -namespace osu.Game.Overlays.Comments -{ - public abstract class ShowChildrenButton : OsuHoverContainer - { - public readonly BindableBool Expanded = new BindableBool(true); - - protected ShowChildrenButton() - { - AutoSizeAxes = Axes.Both; - IdleColour = OsuColour.Gray(0.7f); - HoverColour = Color4.White; - } - - protected override void LoadComplete() - { - Action = Expanded.Toggle; - - Expanded.BindValueChanged(OnExpandedChanged, true); - base.LoadComplete(); - } - - protected abstract void OnExpandedChanged(ValueChangedEvent expanded); - } -} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 79fda99c73..41b25ee1a5 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -225,6 +225,9 @@ namespace osu.Game.Overlays.Dashboard.Friends case OverlayPanelDisplayStyle.List: return new UserListPanel(user); + + case OverlayPanelDisplayStyle.Brick: + return new UserBrickPanel(user); } } diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs new file mode 100644 index 0000000000..4d96825353 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DashboardBeatmapListing : CompositeDrawable + { + private readonly List newBeatmaps; + private readonly List popularBeatmaps; + + public DashboardBeatmapListing(List newBeatmaps, List popularBeatmaps) + { + this.newBeatmaps = newBeatmaps; + this.popularBeatmaps = popularBeatmaps; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new DrawableBeatmapList[] + { + new DrawableNewBeatmapList(newBeatmaps), + new DrawablePopularBeatmapList(popularBeatmaps) + } + }; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs new file mode 100644 index 0000000000..3badea155d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public abstract class DashboardBeatmapPanel : OsuClickableContainer + { + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } + + [Resolved(canBeNull: true)] + private BeatmapSetOverlay beatmapOverlay { get; set; } + + protected readonly BeatmapSetInfo SetInfo; + + private Box hoverBackground; + private SpriteIcon chevron; + + protected DashboardBeatmapPanel(BeatmapSetInfo setInfo) + { + SetInfo = setInfo; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = 60; + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = -10 }, + Child = hoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background3, + Alpha = 0 + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 70), + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 6, + Child = new UpdateableBeatmapSetCover + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BeatmapSet = SetInfo + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 10 }, + Child = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Truncate = true, + Font = OsuFont.GetFont(weight: FontWeight.Regular), + Text = SetInfo.Metadata.Title + }, + new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Truncate = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Text = SetInfo.Metadata.Artist + }, + new LinkFlowContainer(f => f.Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(3), + Margin = new MarginPadding { Top = 2 } + }.With(c => + { + c.AddText("by"); + c.AddUserLink(SetInfo.Metadata.Author); + c.AddArbitraryDrawable(CreateInfo()); + }) + } + } + }, + chevron = new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(16), + Icon = FontAwesome.Solid.ChevronRight, + Colour = ColourProvider.Foreground1 + } + } + } + } + } + }; + + Action = () => + { + if (SetInfo.OnlineBeatmapSetID.HasValue) + beatmapOverlay?.FetchAndShowBeatmapSet(SetInfo.OnlineBeatmapSetID.Value); + }; + } + + protected abstract Drawable CreateInfo(); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + hoverBackground.FadeIn(200, Easing.OutQuint); + chevron.FadeColour(ColourProvider.Light1, 200, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + hoverBackground.FadeOut(200, Easing.OutQuint); + chevron.FadeColour(ColourProvider.Foreground1, 200, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs new file mode 100644 index 0000000000..b212eaf20a --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DashboardNewBeatmapPanel : DashboardBeatmapPanel + { + public DashboardNewBeatmapPanel(BeatmapSetInfo setInfo) + : base(setInfo) + { + } + + protected override Drawable CreateInfo() => new DrawableDate(SetInfo.OnlineInfo.Ranked ?? DateTimeOffset.Now, 10, false) + { + Colour = ColourProvider.Foreground1 + }; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs new file mode 100644 index 0000000000..e9066c0657 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DashboardPopularBeatmapPanel : DashboardBeatmapPanel + { + public DashboardPopularBeatmapPanel(BeatmapSetInfo setInfo) + : base(setInfo) + { + } + + protected override Drawable CreateInfo() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Colour = ColourProvider.Foreground1, + Children = new Drawable[] + { + new SpriteIcon + { + Size = new Vector2(10), + Icon = FontAwesome.Solid.Heart + }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular), + Text = SetInfo.OnlineInfo.FavouriteCount.ToString() + } + } + }; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs new file mode 100644 index 0000000000..f6535b7db3 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public abstract class DrawableBeatmapList : CompositeDrawable + { + private readonly List beatmaps; + + protected DrawableBeatmapList(List beatmaps) + { + this.beatmaps = beatmaps; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + FillFlowContainer flow; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + Colour = colourProvider.Light1, + Text = Title + } + } + }; + + flow.AddRange(beatmaps.Select(CreateBeatmapPanel)); + } + + protected abstract string Title { get; } + + protected abstract DashboardBeatmapPanel CreateBeatmapPanel(BeatmapSetInfo setInfo); + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs new file mode 100644 index 0000000000..75e8ca336d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DrawableNewBeatmapList : DrawableBeatmapList + { + public DrawableNewBeatmapList(List beatmaps) + : base(beatmaps) + { + } + + protected override DashboardBeatmapPanel CreateBeatmapPanel(BeatmapSetInfo setInfo) => new DashboardNewBeatmapPanel(setInfo); + + protected override string Title => "New Ranked Beatmaps"; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs new file mode 100644 index 0000000000..90bd00008c --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DrawablePopularBeatmapList : DrawableBeatmapList + { + public DrawablePopularBeatmapList(List beatmaps) + : base(beatmaps) + { + } + + protected override DashboardBeatmapPanel CreateBeatmapPanel(BeatmapSetInfo setInfo) => new DashboardPopularBeatmapPanel(setInfo); + + protected override string Title => "Popular Beatmaps"; + } +} diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index a72c3f4fa5..e3a4b0e152 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -19,7 +19,6 @@ namespace osu.Game.Overlays { private CancellationTokenSource cancellationToken; - private Box background; private Container content; private DashboardOverlayHeader header; private LoadingLayer loading; @@ -35,9 +34,10 @@ namespace osu.Game.Overlays { Children = new Drawable[] { - background = new Box + new Box { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background5 }, scrollFlow = new OverlayScrollContainer { @@ -66,8 +66,6 @@ namespace osu.Game.Overlays }, loading = new LoadingLayer(content), }; - - background.Colour = ColourProvider.Background5; } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 02ef900dc5..1bcbe4dd2f 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -42,25 +42,34 @@ namespace osu.Game.Overlays.Dialog set => icon.Icon = value; } - private string text; + private string headerText; public string HeaderText { - get => text; + get => headerText; set { - if (text == value) + if (headerText == value) return; - text = value; - + headerText = value; header.Text = value; } } + private string bodyText; + public string BodyText { - set => body.Text = value; + get => bodyText; + set + { + if (bodyText == value) + return; + + bodyText = value; + body.Text = value; + } } public IEnumerable Buttons diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index 7235a18a23..3701f9ecab 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -132,7 +132,7 @@ namespace osu.Game.Overlays.Mods { foreach (var button in buttons) { - int i = Array.FindIndex(button.Mods, m => modTypes.Any(t => t.IsInstanceOfType(m))); + int i = Array.FindIndex(button.Mods, m => modTypes.Any(t => t == m.GetType())); if (i >= 0) button.SelectAt(i); diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 3d0ad1a594..8a5e4d2683 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -19,6 +19,7 @@ using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Overlays.Mods.Sections; using osu.Game.Rulesets.Mods; using osu.Game.Screens; @@ -403,6 +404,8 @@ namespace osu.Game.Overlays.Mods return base.OnKeyDown(e); } + public override bool OnPressed(GlobalAction action) => false; // handled by back button + private void availableModsChanged(ValueChangedEvent>> mods) { if (mods.NewValue == null) return; diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 92cf490be2..a990f9a6ab 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; @@ -71,7 +72,7 @@ namespace osu.Game.Overlays managerRemoved = beatmaps.ItemRemoved.GetBoundCopy(); managerRemoved.BindValueChanged(beatmapRemoved); - beatmapSets.AddRange(beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal).OrderBy(_ => RNG.Next())); + beatmapSets.AddRange(beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal, true).OrderBy(_ => RNG.Next())); } protected override void LoadComplete() @@ -133,6 +134,29 @@ namespace osu.Game.Overlays }); } + /// + /// Ensures music is playing, no matter what, unless the user has explicitly paused. + /// This means that if the current beatmap has a virtual track (see ) a new beatmap will be selected. + /// + public void EnsurePlayingSomething() + { + if (IsUserPaused) return; + + var track = current?.Track; + + if (track == null || track is TrackVirtual) + { + if (beatmap.Disabled) + return; + + NextTrack(); + } + else if (!IsPlaying) + { + Play(); + } + } + /// /// Start playing the current track (if not already playing). /// @@ -144,13 +168,7 @@ namespace osu.Game.Overlays IsUserPaused = false; if (track == null) - { - if (beatmap.Disabled) - return false; - - next(true); - return true; - } + return false; if (restart) track.Restart(); @@ -199,6 +217,9 @@ namespace osu.Game.Overlays /// The that indicate the decided action. private PreviousTrackResult prev() { + if (beatmap.Disabled) + return PreviousTrackResult.None; + var currentTrackPosition = current?.Track.CurrentTime; if (currentTrackPosition >= restart_cutoff_point) @@ -215,8 +236,8 @@ namespace osu.Game.Overlays { if (beatmap is Bindable working) working.Value = beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value); - beatmap.Value.Track.Restart(); + restartTrack(); return PreviousTrackResult.Previous; } @@ -228,10 +249,12 @@ namespace osu.Game.Overlays /// public void NextTrack() => Schedule(() => next()); - private bool next(bool instant = false) + private bool next() { - if (!instant) - queuedDirection = TrackChangeDirection.Next; + if (beatmap.Disabled) + return false; + + queuedDirection = TrackChangeDirection.Next; var playable = BeatmapSets.SkipWhile(i => i.ID != current.BeatmapSetInfo.ID).ElementAtOrDefault(1) ?? BeatmapSets.FirstOrDefault(); @@ -239,13 +262,21 @@ namespace osu.Game.Overlays { if (beatmap is Bindable working) working.Value = beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value); - beatmap.Value.Track.Restart(); + + restartTrack(); return true; } return false; } + private void restartTrack() + { + // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). + // we probably want to move this to a central method for switching to a new working beatmap in the future. + Schedule(() => beatmap.Value.Track.Restart()); + } + private WorkingBeatmap current; private TrackChangeDirection? queuedDirection; diff --git a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs b/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs new file mode 100644 index 0000000000..0f177f151a --- /dev/null +++ b/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs @@ -0,0 +1,114 @@ +// 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 System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osuTK; + +namespace osu.Game.Overlays.News.Displays +{ + public class FrontPageDisplay : CompositeDrawable + { + [Resolved] + private IAPIProvider api { get; set; } + + private FillFlowContainer content; + private ShowMoreButton showMore; + + private GetNewsRequest request; + private Cursor lastCursor; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding + { + Vertical = 20, + Left = 30, + Right = 50 + }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + content = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) + }, + showMore = new ShowMoreButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding + { + Top = 15 + }, + Action = performFetch, + Alpha = 0 + } + } + }; + + performFetch(); + } + + private void performFetch() + { + request?.Cancel(); + + request = new GetNewsRequest(lastCursor); + request.Success += response => Schedule(() => onSuccess(response)); + api.PerformAsync(request); + } + + private CancellationTokenSource cancellationToken; + + private void onSuccess(GetNewsResponse response) + { + cancellationToken?.Cancel(); + + lastCursor = response.Cursor; + + var flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = response.NewsPosts.Select(p => new NewsCard(p)).ToList() + }; + + LoadComponentAsync(flow, loaded => + { + content.Add(loaded); + showMore.IsLoading = false; + showMore.Show(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/News/NewsArticleCover.cs b/osu.Game/Overlays/News/NewsArticleCover.cs deleted file mode 100644 index e3f5a8cea3..0000000000 --- a/osu.Game/Overlays/News/NewsArticleCover.cs +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK.Graphics; - -namespace osu.Game.Overlays.News -{ - public class NewsArticleCover : Container - { - private const int hover_duration = 300; - - private readonly Box gradient; - - public NewsArticleCover(ArticleInfo info) - { - RelativeSizeAxes = Axes.X; - Masking = true; - CornerRadius = 4; - - NewsBackground bg; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.Gray(0.2f), OsuColour.Gray(0.1f)) - }, - new DelayedLoadWrapper(bg = new NewsBackground(info.CoverUrl) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, - Alpha = 0 - }) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - }, - gradient = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.1f), Color4.Black.Opacity(0.7f)), - Alpha = 0 - }, - new DateContainer(info.Time) - { - Margin = new MarginPadding - { - Right = 20, - Top = 20, - } - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding - { - Left = 25, - Bottom = 50, - }, - Font = OsuFont.GetFont(Typeface.Torus, 24, FontWeight.Bold), - Text = info.Title, - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding - { - Left = 25, - Bottom = 30, - }, - Font = OsuFont.GetFont(Typeface.Torus, 16, FontWeight.Bold), - Text = "by " + info.Author - } - }; - - bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); - } - - protected override bool OnHover(HoverEvent e) - { - gradient.FadeIn(hover_duration, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - gradient.FadeOut(hover_duration, Easing.OutQuint); - } - - [LongRunningLoad] - private class NewsBackground : Sprite - { - private readonly string url; - - public NewsBackground(string coverUrl) - { - url = coverUrl ?? "Headers/news"; - } - - [BackgroundDependencyLoader] - private void load(LargeTextureStore store) - { - Texture = store.Get(url); - } - } - - private class DateContainer : Container, IHasTooltip - { - private readonly DateTime date; - - public DateContainer(DateTime date) - { - this.date = date; - - Anchor = Anchor.TopRight; - Origin = Anchor.TopRight; - Masking = true; - CornerRadius = 4; - AutoSizeAxes = Axes.Both; - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(Typeface.Torus, 12, FontWeight.Bold, false, false), - Text = date.ToString("d MMM yyy").ToUpper(), - Margin = new MarginPadding - { - Vertical = 4, - Horizontal = 8, - } - } - }; - } - - public string TooltipText => date.ToString("dddd dd MMMM yyyy hh:mm:ss UTCz").ToUpper(); - } - - // fake API data struct to use for now as a skeleton for data, as there is no API struct for news article info for now - public class ArticleInfo - { - public string Title { get; set; } - public string CoverUrl { get; set; } - public DateTime Time { get; set; } - public string Author { get; set; } - } - } -} diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs new file mode 100644 index 0000000000..201c3ce826 --- /dev/null +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -0,0 +1,195 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.News +{ + public class NewsCard : OsuHoverContainer + { + protected override IEnumerable EffectTargets => new[] { background }; + + private readonly APINewsPost post; + + private Box background; + private TextFlowContainer main; + + public NewsCard(APINewsPost post) + { + this.post = post; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 6; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, GameHost host) + { + if (post.Slug != null) + { + TooltipText = "view in browser"; + Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + } + + NewsBackground bg; + AddRange(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = 160, + Masking = true, + CornerRadius = 6, + Children = new Drawable[] + { + new DelayedLoadWrapper(bg = new NewsBackground(post.FirstImage) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0 + }) + { + RelativeSizeAxes = Axes.Both + }, + new DateContainer(post.PublishedAt) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding + { + Top = 10, + Right = 15 + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 15, + Vertical = 10 + }, + Child = main = new TextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + } + }); + + IdleColour = colourProvider.Background4; + HoverColour = colourProvider.Background3; + + bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); + + main.AddParagraph(post.Title, t => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold)); + main.AddParagraph(post.Preview, t => t.Font = OsuFont.GetFont(size: 12)); // Should use sans-serif font + main.AddParagraph("by ", t => t.Font = OsuFont.GetFont(size: 12)); + main.AddText(post.Author, t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)); + } + + [LongRunningLoad] + private class NewsBackground : Sprite + { + private readonly string sourceUrl; + + public NewsBackground(string sourceUrl) + { + this.sourceUrl = sourceUrl; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore store) + { + Texture = store.Get(createUrl(sourceUrl)); + } + + private string createUrl(string source) + { + if (string.IsNullOrEmpty(source)) + return "Headers/news"; + + if (source.StartsWith('/')) + return "https://osu.ppy.sh" + source; + + return source; + } + } + + private class DateContainer : CircularContainer, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => date; + + private readonly DateTimeOffset date; + + public DateContainer(DateTimeOffset date) + { + this.date = date; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Masking = true; + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6.Opacity(0.5f) + }, + new OsuSpriteText + { + Text = date.ToString("d MMM yyyy").ToUpper(), + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Margin = new MarginPadding + { + Horizontal = 20, + Vertical = 5 + } + } + }; + } + + protected override bool OnClick(ClickEvent e) => true; // Protects the NewsCard from clicks while hovering DateContainer + } + } +} diff --git a/osu.Game/Overlays/News/NewsContent.cs b/osu.Game/Overlays/News/NewsContent.cs deleted file mode 100644 index 5ff210f9f5..0000000000 --- a/osu.Game/Overlays/News/NewsContent.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; - -namespace osu.Game.Overlays.News -{ - public abstract class NewsContent : FillFlowContainer - { - protected NewsContent() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Direction = FillDirection.Vertical; - Padding = new MarginPadding { Bottom = 100, Top = 20, Horizontal = 50 }; - } - } -} diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 8214c71b3a..ddada2bdaf 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Framework.Graphics; -using System; namespace osu.Game.Overlays.News { @@ -11,24 +11,28 @@ namespace osu.Game.Overlays.News { private const string front_page_string = "frontpage"; - public readonly Bindable Post = new Bindable(null); - public Action ShowFrontPage; + private readonly Bindable article = new Bindable(null); + public NewsHeader() { TabControl.AddItem(front_page_string); - Current.ValueChanged += e => + Current.BindValueChanged(e => { if (e.NewValue == front_page_string) ShowFrontPage?.Invoke(); - }; + }); - Post.ValueChanged += showPost; + article.BindValueChanged(onArticleChanged, true); } - private void showPost(ValueChangedEvent e) + public void SetFrontPage() => article.Value = null; + + public void SetArticle(string slug) => article.Value = slug; + + private void onArticleChanged(ValueChangedEvent e) { if (e.OldValue != null) TabControl.RemoveItem(e.OldValue); diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index 46d692d44d..09fb445b1f 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -7,18 +7,20 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.News; +using osu.Game.Overlays.News.Displays; namespace osu.Game.Overlays { public class NewsOverlay : FullscreenOverlay { + private readonly Bindable article = new Bindable(null); + + private Container content; + private LoadingLayer loading; private NewsHeader header; - - private Container content; - - public readonly Bindable Current = new Bindable(null); + private OverlayScrollContainer scrollFlow; public NewsOverlay() : base(OverlayColourScheme.Purple) @@ -26,18 +28,19 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.PurpleDarkAlternative + Colour = ColourProvider.Background5, }, - new OverlayScrollContainer + scrollFlow = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, @@ -49,7 +52,7 @@ namespace osu.Game.Overlays { ShowFrontPage = ShowFrontPage }, - content = new Container + content = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -57,31 +60,81 @@ namespace osu.Game.Overlays }, }, }, + loading = new LoadingLayer(content), }; - - header.Post.BindTo(Current); - Current.TriggerChange(); } - private CancellationTokenSource loadContentCancellation; - - protected void LoadAndShowContent(NewsContent newContent) + protected override void LoadComplete() { - content.FadeTo(0.2f, 300, Easing.OutQuint); + base.LoadComplete(); - loadContentCancellation?.Cancel(); + // should not be run until first pop-in to avoid requesting data before user views. + article.BindValueChanged(onArticleChanged); + } - LoadComponentAsync(newContent, c => + private bool displayUpdateRequired = true; + + protected override void PopIn() + { + base.PopIn(); + + if (displayUpdateRequired) { - content.Child = c; - content.FadeIn(300, Easing.OutQuint); - }, (loadContentCancellation = new CancellationTokenSource()).Token); + article.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + displayUpdateRequired = true; } public void ShowFrontPage() { - Current.Value = null; + article.Value = null; Show(); } + + public void ShowArticle(string slug) + { + article.Value = slug; + Show(); + } + + private CancellationTokenSource cancellationToken; + + private void onArticleChanged(ValueChangedEvent e) + { + cancellationToken?.Cancel(); + loading.Show(); + + if (e.NewValue == null) + { + header.SetFrontPage(); + LoadDisplay(new FrontPageDisplay()); + return; + } + + header.SetArticle(e.NewValue); + LoadDisplay(Empty()); + } + + protected void LoadDisplay(Drawable display) + { + scrollFlow.ScrollToStart(); + LoadComponentAsync(display, loaded => + { + content.Child = loaded; + loading.Hide(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Overlays/OverlayHeader.cs b/osu.Game/Overlays/OverlayHeader.cs index dbc934bde9..cc7f798c4a 100644 --- a/osu.Game/Overlays/OverlayHeader.cs +++ b/osu.Game/Overlays/OverlayHeader.cs @@ -12,9 +12,26 @@ namespace osu.Game.Overlays { public abstract class OverlayHeader : Container { - public const int CONTENT_X_MARGIN = 50; + private float contentSidePadding; + + /// + /// Horizontal padding of the header content. + /// + protected float ContentSidePadding + { + get => contentSidePadding; + set + { + contentSidePadding = value; + content.Padding = new MarginPadding + { + Horizontal = value + }; + } + } private readonly Box titleBackground; + private readonly Container content; protected readonly FillFlowContainer HeaderInfo; @@ -50,14 +67,10 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = Color4.Gray, }, - new Container + content = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Horizontal = CONTENT_X_MARGIN, - }, Children = new[] { CreateTitle().With(title => @@ -79,6 +92,8 @@ namespace osu.Game.Overlays CreateContent() } }); + + ContentSidePadding = 50; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index 7269007b41..87b9d89d4d 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -34,6 +34,10 @@ namespace osu.Game.Overlays { Icon = FontAwesome.Solid.Bars }); + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.Brick) + { + Icon = FontAwesome.Solid.Th + }); } protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer @@ -96,6 +100,7 @@ namespace osu.Game.Overlays public enum OverlayPanelDisplayStyle { Card, - List + List, + Brick } } diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index 395f3aec4c..b2212336ef 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -30,6 +30,14 @@ namespace osu.Game.Overlays set => current.Current = value; } + public string Title + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + public OverlaySortTabControl() { AutoSizeAxes = Axes.Both; @@ -40,11 +48,11 @@ namespace osu.Game.Overlays Spacing = new Vector2(10, 0), Children = new Drawable[] { - new OsuSpriteText + text = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 12), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = @"Sort by" }, CreateControl().With(c => @@ -133,7 +141,7 @@ namespace osu.Game.Overlays { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 12), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = (value as Enum)?.GetDescription() ?? value.ToString() } } @@ -163,7 +171,7 @@ namespace osu.Game.Overlays ContentColour = Active.Value && !IsHovered ? colourProvider.Light1 : Color4.White; - text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Medium); + text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.SemiBold); } } } diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 0161d91daa..55474c9d3e 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -23,6 +23,8 @@ namespace osu.Game.Overlays.Profile public ProfileHeader() { + ContentSidePadding = UserProfileOverlay.CONTENT_X_MARGIN; + User.ValueChanged += e => updateDisplay(e.NewValue); TabControl.AddItem("info"); @@ -39,7 +41,7 @@ namespace osu.Game.Overlays.Profile Masking = true, Children = new Drawable[] { - coverContainer = new UserCoverBackground + coverContainer = new ProfileCoverBackground { RelativeSizeAxes = Axes.Both, }, @@ -98,5 +100,10 @@ namespace osu.Game.Overlays.Profile IconTexture = "Icons/profile"; } } + + private class ProfileCoverBackground : UserCoverBackground + { + protected override double LoadDelay => 0; + } } } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs index a30ff786fb..9720469548 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs @@ -14,12 +14,13 @@ using osu.Game.Users; using System.Collections.Generic; using System.Linq; using System.Threading; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Sections { public abstract class PaginatedContainer : FillFlowContainer { - private readonly ProfileShowMoreButton moreButton; + private readonly ShowMoreButton moreButton; private readonly OsuSpriteText missingText; private APIRequest> retrievalRequest; private CancellationTokenSource loadCancellation; @@ -74,7 +75,7 @@ namespace osu.Game.Overlays.Profile.Sections RelativeSizeAxes = Axes.X, Spacing = new Vector2(0, 2), }, - moreButton = new ProfileShowMoreButton + moreButton = new ShowMoreButton { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs b/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs deleted file mode 100644 index 426ebeebe6..0000000000 --- a/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Overlays.Profile.Sections -{ - public class ProfileShowMoreButton : ShowMoreButton - { - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - IdleColour = colourProvider.Background2; - HoverColour = colourProvider.Background1; - ChevronIconColour = colourProvider.Foreground1; - } - } -} diff --git a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs new file mode 100644 index 0000000000..c0bbf46e30 --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Overlays.Rankings +{ + public class RankingsSortTabControl : OverlaySortTabControl + { + public RankingsSortTabControl() + { + Title = "Show"; + } + } + + public enum RankingsSortCriteria + { + All, + Friends + } +} diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index f019b50ae8..422373d099 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -18,14 +18,10 @@ using osu.Game.Online.API.Requests; namespace osu.Game.Overlays.Rankings { - public class SpotlightSelector : VisibilityContainer, IHasCurrentValue + public class SpotlightSelector : CompositeDrawable, IHasCurrentValue { - private const int duration = 300; - - private readonly Box background; - private readonly SpotlightsDropdown dropdown; - private readonly BindableWithCurrent current = new BindableWithCurrent(); + public readonly Bindable Sort = new Bindable(); public Bindable Current { @@ -39,60 +35,79 @@ namespace osu.Game.Overlays.Rankings set => dropdown.Items = value; } - protected override bool StartHidden => true; - + private readonly Box background; + private readonly SpotlightsDropdown dropdown; private readonly InfoColumn startDateColumn; private readonly InfoColumn endDateColumn; private readonly InfoColumn mapCountColumn; private readonly InfoColumn participantsColumn; - private readonly Container content; public SpotlightSelector() { RelativeSizeAxes = Axes.X; - Height = 100; - Add(content = new Container + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + background = new Box { - background = new Box + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN }, + Child = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Vertical = 10 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, Children = new Drawable[] { - dropdown = new SpotlightsDropdown + new Container { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + Margin = new MarginPadding { Vertical = 20 }, RelativeSizeAxes = Axes.X, - Current = Current, - Depth = -float.MaxValue + Height = 40, + Depth = -float.MaxValue, + Child = dropdown = new SpotlightsDropdown + { + RelativeSizeAxes = Axes.X, + Current = Current + } }, - new FillFlowContainer + new Container { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(15, 0), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Children = new Drawable[] { - startDateColumn = new InfoColumn(@"Start Date"), - endDateColumn = new InfoColumn(@"End Date"), - mapCountColumn = new InfoColumn(@"Map Count"), - participantsColumn = new InfoColumn(@"Participants") + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Bottom = 5 }, + Children = new Drawable[] + { + startDateColumn = new InfoColumn(@"Start Date"), + endDateColumn = new InfoColumn(@"End Date"), + mapCountColumn = new InfoColumn(@"Map Count"), + participantsColumn = new InfoColumn(@"Participants") + } + }, + new RankingsSortTabControl + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Current = Sort + } } } } } } - }); + }; } [BackgroundDependencyLoader] @@ -109,10 +124,6 @@ namespace osu.Game.Overlays.Rankings participantsColumn.Value = response.Spotlight.Participants?.ToString("N0"); } - protected override void PopIn() => content.FadeIn(duration, Easing.OutQuint); - - protected override void PopOut() => content.FadeOut(duration, Easing.OutQuint); - private string dateToString(DateTimeOffset date) => date.ToString("yyyy-MM-dd"); private class InfoColumn : FillFlowContainer @@ -128,12 +139,13 @@ namespace osu.Game.Overlays.Rankings { AutoSizeAxes = Axes.Both; Direction = FillDirection.Vertical; + Margin = new MarginPadding { Vertical = 10 }; Children = new Drawable[] { new OsuSpriteText { Text = name, - Font = OsuFont.GetFont(size: 10), + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular), }, new Container { @@ -143,7 +155,7 @@ namespace osu.Game.Overlays.Rankings { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Light), + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Light), } } }; diff --git a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs index 917509e842..61339df76f 100644 --- a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs +++ b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs @@ -24,6 +24,7 @@ namespace osu.Game.Overlays.Rankings public readonly Bindable Ruleset = new Bindable(); private readonly Bindable selectedSpotlight = new Bindable(); + private readonly Bindable sort = new Bindable(); [Resolved] private IAPIProvider api { get; set; } @@ -72,15 +73,16 @@ namespace osu.Game.Overlays.Rankings } } }; + + sort.BindTo(selector.Sort); } protected override void LoadComplete() { base.LoadComplete(); - selector.Show(); - - selectedSpotlight.BindValueChanged(onSpotlightChanged); + selectedSpotlight.BindValueChanged(_ => onSpotlightChanged()); + sort.BindValueChanged(_ => onSpotlightChanged()); Ruleset.BindValueChanged(onRulesetChanged); getSpotlights(); @@ -101,14 +103,14 @@ namespace osu.Game.Overlays.Rankings selectedSpotlight.TriggerChange(); } - private void onSpotlightChanged(ValueChangedEvent spotlight) + private void onSpotlightChanged() { loading.Show(); cancellationToken?.Cancel(); getRankingsRequest?.Cancel(); - getRankingsRequest = new GetSpotlightRankingsRequest(Ruleset.Value, spotlight.NewValue.Id); + getRankingsRequest = new GetSpotlightRankingsRequest(Ruleset.Value, selectedSpotlight.Value.Id, sort.Value); getRankingsRequest.Success += onSuccess; api.Queue(getRankingsRequest); } diff --git a/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs b/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs index 5ecb477a2f..ffbc1c9586 100644 --- a/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs +++ b/osu.Game/Overlays/SearchableList/DisplayStyleControl.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 osu.Framework.Bindables; using osuTK; using osu.Framework.Graphics; @@ -11,44 +10,23 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.SearchableList { - public class DisplayStyleControl : Container - where T : struct, Enum + public class DisplayStyleControl : CompositeDrawable { - public readonly SlimEnumDropdown Dropdown; public readonly Bindable DisplayStyle = new Bindable(); public DisplayStyleControl() { AutoSizeAxes = Axes.Both; - Children = new[] + InternalChild = new FillFlowContainer { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5f, 0f), + Direction = FillDirection.Horizontal, + Children = new[] { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Spacing = new Vector2(10f, 0f), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f, 0f), - Direction = FillDirection.Horizontal, - Children = new[] - { - new DisplayStyleToggleButton(FontAwesome.Solid.ThLarge, PanelDisplayStyle.Grid, DisplayStyle), - new DisplayStyleToggleButton(FontAwesome.Solid.ListUl, PanelDisplayStyle.List, DisplayStyle), - }, - }, - Dropdown = new SlimEnumDropdown - { - RelativeSizeAxes = Axes.None, - Width = 160f, - }, - }, + new DisplayStyleToggleButton(FontAwesome.Solid.ThLarge, PanelDisplayStyle.Grid, DisplayStyle), + new DisplayStyleToggleButton(FontAwesome.Solid.ListUl, PanelDisplayStyle.List, DisplayStyle), }, }; diff --git a/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs b/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs index d31470e685..e0163b5b0c 100644 --- a/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs +++ b/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs @@ -19,12 +19,14 @@ namespace osu.Game.Overlays.SearchableList { private const float padding = 10; - private readonly Container filterContainer; + private readonly Drawable filterContainer; + private readonly Drawable rightFilterContainer; private readonly Box tabStrip; public readonly SearchTextBox Search; public readonly PageTabControl Tabs; - public readonly DisplayStyleControl DisplayStyleControl; + public readonly SlimEnumDropdown Dropdown; + public readonly DisplayStyleControl DisplayStyleControl; protected abstract Color4 BackgroundColour { get; } protected abstract TTab DefaultTab { get; } @@ -42,7 +44,7 @@ namespace osu.Game.Overlays.SearchableList var controls = CreateSupplementaryControls(); Container controlsContainer; - Children = new Drawable[] + Children = new[] { filterContainer = new Container { @@ -104,11 +106,27 @@ namespace osu.Game.Overlays.SearchableList }, }, }, - DisplayStyleControl = new DisplayStyleControl + rightFilterContainer = new FillFlowContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - }, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + Dropdown = new SlimEnumDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.None, + Width = 160f, + }, + DisplayStyleControl = new DisplayStyleControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + } }; if (controls != null) controlsContainer.Children = new[] { controls }; @@ -116,8 +134,8 @@ namespace osu.Game.Overlays.SearchableList Tabs.Current.Value = DefaultTab; Tabs.Current.TriggerChange(); - DisplayStyleControl.Dropdown.Current.Value = DefaultCategory; - DisplayStyleControl.Dropdown.Current.TriggerChange(); + Dropdown.Current.Value = DefaultCategory; + Dropdown.Current.TriggerChange(); } [BackgroundDependencyLoader] @@ -131,13 +149,11 @@ namespace osu.Game.Overlays.SearchableList base.Update(); Height = filterContainer.Height; - DisplayStyleControl.Margin = new MarginPadding { Top = filterContainer.Height - 35, Right = SearchableListOverlay.WIDTH_PADDING }; + rightFilterContainer.Margin = new MarginPadding { Top = filterContainer.Height - 30, Right = ContentHorizontalPadding }; } private class FilterSearchTextBox : SearchTextBox { - protected override bool AllowCommit => true; - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 93a02ea0e4..0149e6c3a6 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; @@ -78,6 +79,15 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Bindable = config.GetBindable(OsuSetting.ScoreDisplayMode) } }; + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + Add(new SettingsCheckbox + { + LabelText = "Disable Windows key during gameplay", + Bindable = config.GetBindable(OsuSetting.GameplayDisableWinKey) + }); + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs index 52b712a40e..34e5da4ef4 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved] private OsuColour colours { get; set; } - private UserPanel panel; + private UserGridPanel panel; private UserDropdown dropdown; /// diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 04390a1193..596d3a9801 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -116,8 +116,6 @@ namespace osu.Game.Overlays.Settings.Sections private class SkinDropdownControl : DropdownControl { protected override string GenerateItemText(SkinInfo item) => item.ToString(); - - protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 200); } } diff --git a/osu.Game/Overlays/Settings/SettingsDropdown.cs b/osu.Game/Overlays/Settings/SettingsDropdown.cs index 167061f485..1175ddaab8 100644 --- a/osu.Game/Overlays/Settings/SettingsDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsDropdown.cs @@ -38,6 +38,8 @@ namespace osu.Game.Overlays.Settings Margin = new MarginPadding { Top = 5 }; RelativeSizeAxes = Axes.X; } + + protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 200); } } } diff --git a/osu.Game/Overlays/Social/FilterControl.cs b/osu.Game/Overlays/Social/FilterControl.cs deleted file mode 100644 index 93fcc3c401..0000000000 --- a/osu.Game/Overlays/Social/FilterControl.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Extensions.Color4Extensions; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Game.Overlays.SearchableList; - -namespace osu.Game.Overlays.Social -{ - public class FilterControl : SearchableListFilterControl - { - protected override Color4 BackgroundColour => Color4Extensions.FromHex(@"47253a"); - protected override SocialSortCriteria DefaultTab => SocialSortCriteria.Rank; - protected override SortDirection DefaultCategory => SortDirection.Ascending; - - public FilterControl() - { - Tabs.Margin = new MarginPadding { Top = 10 }; - } - } - - public enum SocialSortCriteria - { - Rank, - Name, - Location, - //[Description("Time Zone")] - //TimeZone, - //[Description("World Map")] - //WorldMap, - } -} diff --git a/osu.Game/Overlays/Social/Header.cs b/osu.Game/Overlays/Social/Header.cs deleted file mode 100644 index 22e0fdcd56..0000000000 --- a/osu.Game/Overlays/Social/Header.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Overlays.SearchableList; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Framework.Allocation; -using System.ComponentModel; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics.Sprites; - -namespace osu.Game.Overlays.Social -{ - public class Header : SearchableListHeader - { - private OsuSpriteText browser; - - protected override Color4 BackgroundColour => Color4Extensions.FromHex(@"38202e"); - - protected override SocialTab DefaultTab => SocialTab.AllPlayers; - protected override IconUsage Icon => FontAwesome.Solid.Users; - - protected override Drawable CreateHeaderText() - { - return new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - new OsuSpriteText - { - Text = "social ", - Font = OsuFont.GetFont(size: 25), - }, - browser = new OsuSpriteText - { - Text = "browser", - Font = OsuFont.GetFont(size: 25, weight: FontWeight.Light), - }, - }, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - browser.Colour = colours.Pink; - } - } - - public enum SocialTab - { - [Description("All Players")] - AllPlayers, - - [Description("Friends")] - Friends, - //[Description("Team Members")] - //TeamMembers, - //[Description("Chat Channels")] - //ChatChannels, - } -} diff --git a/osu.Game/Overlays/SocialOverlay.cs b/osu.Game/Overlays/SocialOverlay.cs deleted file mode 100644 index 9548573b4f..0000000000 --- a/osu.Game/Overlays/SocialOverlay.cs +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.SearchableList; -using osu.Game.Overlays.Social; -using osu.Game.Users; -using System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Threading; - -namespace osu.Game.Overlays -{ - public class SocialOverlay : SearchableListOverlay - { - private readonly LoadingSpinner loading; - private FillFlowContainer panels; - - protected override Color4 BackgroundColour => Color4Extensions.FromHex(@"60284b"); - protected override Color4 TrianglesColourLight => Color4Extensions.FromHex(@"672b51"); - protected override Color4 TrianglesColourDark => Color4Extensions.FromHex(@"5c2648"); - - protected override SearchableListHeader CreateHeader() => new Header(); - protected override SearchableListFilterControl CreateFilterControl() => new FilterControl(); - - private User[] users = Array.Empty(); - - public User[] Users - { - get => users; - set - { - if (users == value) - return; - - users = value ?? Array.Empty(); - - if (LoadState >= LoadState.Ready) - recreatePanels(); - } - } - - public SocialOverlay() - : base(OverlayColourScheme.Pink) - { - Add(loading = new LoadingSpinner()); - - Filter.Search.Current.ValueChanged += text => - { - if (!string.IsNullOrEmpty(text.NewValue)) - { - // force searching in players until searching for friends is supported - Header.Tabs.Current.Value = SocialTab.AllPlayers; - - if (Filter.Tabs.Current.Value != SocialSortCriteria.Rank) - Filter.Tabs.Current.Value = SocialSortCriteria.Rank; - } - }; - - Header.Tabs.Current.ValueChanged += _ => queueUpdate(); - Filter.Tabs.Current.ValueChanged += _ => onFilterUpdate(); - - Filter.DisplayStyleControl.DisplayStyle.ValueChanged += _ => recreatePanels(); - Filter.DisplayStyleControl.Dropdown.Current.ValueChanged += _ => recreatePanels(); - - currentQuery.BindTo(Filter.Search.Current); - currentQuery.ValueChanged += query => - { - queryChangedDebounce?.Cancel(); - - if (string.IsNullOrEmpty(query.NewValue)) - queueUpdate(); - else - queryChangedDebounce = Scheduler.AddDelayed(updateSearch, 500); - }; - } - - [BackgroundDependencyLoader] - private void load() - { - recreatePanels(); - } - - private APIRequest getUsersRequest; - - private readonly Bindable currentQuery = new Bindable(); - - private ScheduledDelegate queryChangedDebounce; - - private void queueUpdate() => Scheduler.AddOnce(updateSearch); - - private CancellationTokenSource loadCancellation; - - private void updateSearch() - { - queryChangedDebounce?.Cancel(); - - if (!IsLoaded) - return; - - Users = null; - clearPanels(); - getUsersRequest?.Cancel(); - - if (API?.IsLoggedIn != true) - return; - - switch (Header.Tabs.Current.Value) - { - case SocialTab.Friends: - var friendRequest = new GetFriendsRequest(); // TODO filter arguments? - friendRequest.Success += users => Users = users.ToArray(); - API.Queue(getUsersRequest = friendRequest); - break; - - default: - var userRequest = new GetUsersRequest(); // TODO filter arguments! - userRequest.Success += res => Users = res.Users.Select(r => r.User).ToArray(); - API.Queue(getUsersRequest = userRequest); - break; - } - } - - private void recreatePanels() - { - clearPanels(); - - if (Users == null) - { - loading.Hide(); - return; - } - - IEnumerable sortedUsers = Users; - - switch (Filter.Tabs.Current.Value) - { - case SocialSortCriteria.Location: - sortedUsers = sortedUsers.OrderBy(u => u.Country.FullName); - break; - - case SocialSortCriteria.Name: - sortedUsers = sortedUsers.OrderBy(u => u.Username); - break; - } - - if (Filter.DisplayStyleControl.Dropdown.Current.Value == SortDirection.Descending) - sortedUsers = sortedUsers.Reverse(); - - var newPanels = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10f), - Margin = new MarginPadding { Top = 10 }, - ChildrenEnumerable = sortedUsers.Select(u => - { - UserPanel panel; - - switch (Filter.DisplayStyleControl.DisplayStyle.Value) - { - case PanelDisplayStyle.Grid: - panel = new UserGridPanel(u) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Width = 290, - }; - break; - - default: - panel = new UserListPanel(u); - break; - } - - panel.Status.BindTo(u.Status); - panel.Activity.BindTo(u.Activity); - return panel; - }) - }; - - LoadComponentAsync(newPanels, f => - { - if (panels != null) - ScrollFlow.Remove(panels); - - loading.Hide(); - ScrollFlow.Add(panels = newPanels); - }, (loadCancellation = new CancellationTokenSource()).Token); - } - - private void onFilterUpdate() - { - if (Filter.Tabs.Current.Value == SocialSortCriteria.Rank) - { - queueUpdate(); - return; - } - - recreatePanels(); - } - - private void clearPanels() - { - loading.Show(); - - loadCancellation?.Cancel(); - - if (panels != null) - { - panels.Expire(); - panels = null; - } - } - - public override void APIStateChanged(IAPIProvider api, APIState state) - { - switch (state) - { - case APIState.Online: - queueUpdate(); - break; - - default: - Users = null; - clearPanels(); - break; - } - } - } -} diff --git a/osu.Game/Overlays/TabControlOverlayHeader.cs b/osu.Game/Overlays/TabControlOverlayHeader.cs index e8e000f441..61605d9e9e 100644 --- a/osu.Game/Overlays/TabControlOverlayHeader.cs +++ b/osu.Game/Overlays/TabControlOverlayHeader.cs @@ -22,6 +22,7 @@ namespace osu.Game.Overlays protected OsuTabControl TabControl; private readonly Box controlBackground; + private readonly Container tabControlContainer; private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current @@ -30,6 +31,16 @@ namespace osu.Game.Overlays set => current.Current = value; } + protected new float ContentSidePadding + { + get => base.ContentSidePadding; + set + { + base.ContentSidePadding = value; + tabControlContainer.Padding = new MarginPadding { Horizontal = value }; + } + } + protected TabControlOverlayHeader() { HeaderInfo.Add(new Container @@ -42,11 +53,16 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both, }, - TabControl = CreateTabControl().With(control => + tabControlContainer = new Container { - control.Margin = new MarginPadding { Left = CONTENT_X_MARGIN }; - control.Current = Current; - }) + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = ContentSidePadding }, + Child = TabControl = CreateTabControl().With(control => + { + control.Current = Current; + }) + } } }); } diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index 1b748cb672..5bdd86c671 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -28,9 +28,6 @@ namespace osu.Game.Overlays.Toolbar private const double transition_time = 500; - private const float alpha_hovering = 0.8f; - private const float alpha_normal = 0.6f; - private readonly Bindable overlayActivationMode = new Bindable(OverlayActivation.All); // Toolbar components like RulesetSelector should receive keyboard input events even when the toolbar is hidden. @@ -72,6 +69,7 @@ namespace osu.Game.Overlays.Toolbar AutoSizeAxes = Axes.X, Children = new Drawable[] { + new ToolbarNewsButton(), new ToolbarChangelogButton(), new ToolbarRankingsButton(), new ToolbarBeatmapListingButton(), @@ -103,7 +101,6 @@ namespace osu.Game.Overlays.Toolbar public class ToolbarBackground : Container { - private readonly Box solidBackground; private readonly Box gradientBackground; public ToolbarBackground() @@ -111,11 +108,10 @@ namespace osu.Game.Overlays.Toolbar RelativeSizeAxes = Axes.Both; Children = new Drawable[] { - solidBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.1f), - Alpha = alpha_normal, }, gradientBackground = new Box { @@ -131,14 +127,12 @@ namespace osu.Game.Overlays.Toolbar protected override bool OnHover(HoverEvent e) { - solidBackground.FadeTo(alpha_hovering, transition_time, Easing.OutQuint); gradientBackground.FadeIn(transition_time, Easing.OutQuint); return true; } protected override void OnHoverLost(HoverLostEvent e) { - solidBackground.FadeTo(alpha_normal, transition_time, Easing.OutQuint); gradientBackground.FadeOut(transition_time, Easing.OutQuint); } } @@ -146,7 +140,7 @@ namespace osu.Game.Overlays.Toolbar protected override void PopIn() { this.MoveToY(0, transition_time, Easing.OutQuint); - this.FadeIn(transition_time / 2, Easing.OutQuint); + this.FadeIn(transition_time / 4, Easing.OutQuint); } protected override void PopOut() @@ -154,7 +148,7 @@ namespace osu.Game.Overlays.Toolbar userButton.StateContainer?.Hide(); this.MoveToY(-DrawSize.Y, transition_time, Easing.OutQuint); - this.FadeOut(transition_time); + this.FadeOut(transition_time, Easing.InQuint); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs index eecb368ee9..cde305fffd 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Game.Graphics; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -11,6 +12,10 @@ namespace osu.Game.Overlays.Toolbar public ToolbarBeatmapListingButton() { SetIcon(OsuIcon.ChevronDownCircle); + TooltipMain = "Beatmap listing"; + TooltipSub = "Browse for new beatmaps"; + + Hotkey = GlobalAction.ToggleDirect; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 86a3f5d8aa..0afc6642b2 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,27 +1,34 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Toolbar { - public class ToolbarButton : OsuClickableContainer + public abstract class ToolbarButton : OsuClickableContainer, IKeyBindingHandler { public const float WIDTH = Toolbar.HEIGHT * 1.4f; + protected GlobalAction? Hotkey { get; set; } + public void SetIcon(Drawable icon) { IconContainer.Icon = icon; @@ -66,9 +73,13 @@ namespace osu.Game.Overlays.Toolbar private readonly FillFlowContainer tooltipContainer; private readonly SpriteText tooltip1; private readonly SpriteText tooltip2; + private readonly SpriteText keyBindingTooltip; protected FillFlowContainer Flow; - public ToolbarButton() + [Resolved] + private KeyBindingStore keyBindings { get; set; } + + protected ToolbarButton() : base(HoverSampleSet.Loud) { Width = WIDTH; @@ -123,7 +134,7 @@ namespace osu.Game.Overlays.Toolbar Origin = TooltipAnchor, Position = new Vector2(TooltipAnchor.HasFlag(Anchor.x0) ? 5 : -5, 5), Alpha = 0, - Children = new[] + Children = new Drawable[] { tooltip1 = new OsuSpriteText { @@ -132,17 +143,44 @@ namespace osu.Game.Overlays.Toolbar Shadow = true, Font = OsuFont.GetFont(size: 22, weight: FontWeight.Bold), }, - tooltip2 = new OsuSpriteText + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = TooltipAnchor, Origin = TooltipAnchor, - Shadow = true, + Direction = FillDirection.Horizontal, + Children = new[] + { + tooltip2 = new OsuSpriteText { Shadow = true }, + keyBindingTooltip = new OsuSpriteText { Shadow = true } + } } } } }; } + private readonly Cached tooltipKeyBinding = new Cached(); + + [BackgroundDependencyLoader] + private void load() + { + keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate(); + updateKeyBindingTooltip(); + } + + private void updateKeyBindingTooltip() + { + if (tooltipKeyBinding.IsValid) + return; + + var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey); + var keyBindingString = binding?.KeyCombination.ReadableString(); + keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty; + + tooltipKeyBinding.Validate(); + } + protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) @@ -154,6 +192,8 @@ namespace osu.Game.Overlays.Toolbar protected override bool OnHover(HoverEvent e) { + updateKeyBindingTooltip(); + HoverBackground.FadeIn(200); tooltipContainer.FadeIn(100); return base.OnHover(e); @@ -164,6 +204,21 @@ namespace osu.Game.Overlays.Toolbar HoverBackground.FadeOut(200); tooltipContainer.FadeOut(100); } + + public bool OnPressed(GlobalAction action) + { + if (action == Hotkey) + { + Click(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } public class OpaqueBackground : Container diff --git a/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs b/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs index 84210e27a4..c88b418853 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs @@ -11,6 +11,8 @@ namespace osu.Game.Overlays.Toolbar public ToolbarChangelogButton() { SetIcon(FontAwesome.Solid.Bullhorn); + TooltipMain = "Changelog"; + TooltipSub = "Track recent dev updates in the osu! ecosystem"; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs b/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs index ad0e5be551..dee4be0c1f 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -11,6 +12,10 @@ namespace osu.Game.Overlays.Toolbar public ToolbarChatButton() { SetIcon(FontAwesome.Solid.Comments); + TooltipMain = "Chat"; + TooltipSub = "Join the real-time discussion"; + + Hotkey = GlobalAction.ToggleChat; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs index e642f0c453..4845c9a99f 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs @@ -2,33 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Bindings; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarHomeButton : ToolbarButton, IKeyBindingHandler + public class ToolbarHomeButton : ToolbarButton { public ToolbarHomeButton() { Icon = FontAwesome.Solid.Home; TooltipMain = "Home"; TooltipSub = "Return to the main menu"; - } - public bool OnPressed(GlobalAction action) - { - if (action == GlobalAction.Home) - { - Click(); - return true; - } - - return false; - } - - public void OnReleased(GlobalAction action) - { + Hotkey = GlobalAction.Home; } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs index b29aec5842..f9aa2de497 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs @@ -2,15 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { public class ToolbarMusicButton : ToolbarOverlayToggleButton { + protected override Anchor TooltipAnchor => Anchor.TopRight; + public ToolbarMusicButton() { Icon = FontAwesome.Solid.Music; + TooltipMain = "Now playing"; + TooltipSub = "Manage the currently playing track"; + + Hotkey = GlobalAction.ToggleNowPlaying; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs new file mode 100644 index 0000000000..106c67a041 --- /dev/null +++ b/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Overlays.Toolbar +{ + public class ToolbarNewsButton : ToolbarOverlayToggleButton + { + public ToolbarNewsButton() + { + Icon = FontAwesome.Solid.Newspaper; + TooltipMain = "News"; + TooltipSub = "Get up-to-date on community happenings"; + } + + [BackgroundDependencyLoader(true)] + private void load(NewsOverlay news) + { + StateContainer = news; + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs b/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs index dbd6c557d3..a699fd907f 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -28,6 +29,8 @@ namespace osu.Game.Overlays.Toolbar TooltipMain = "Notifications"; TooltipSub = "Waiting for 'ya"; + Hotkey = GlobalAction.ToggleNotifications; + Add(countDisplay = new CountCircle { Alpha = 0, diff --git a/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs index cbd097696d..c026ce99fe 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs @@ -11,6 +11,8 @@ namespace osu.Game.Overlays.Toolbar public ToolbarRankingsButton() { SetIcon(FontAwesome.Regular.ChartBar); + TooltipMain = "Ranking"; + TooltipSub = "Find out who's the best right now"; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs index 79942012f9..ed2a23ec2a 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -13,6 +14,8 @@ namespace osu.Game.Overlays.Toolbar Icon = FontAwesome.Solid.Cog; TooltipMain = "Settings"; TooltipSub = "Change your settings"; + + Hotkey = GlobalAction.ToggleSettings; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs index f6646eb81d..6faa58c559 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -11,6 +12,10 @@ namespace osu.Game.Overlays.Toolbar public ToolbarSocialButton() { Icon = FontAwesome.Solid.Users; + TooltipMain = "Friends"; + TooltipSub = "Interact with those close to you"; + + Hotkey = GlobalAction.ToggleSocial; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 7113acbbfb..d24c81536e 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using 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; @@ -18,19 +21,20 @@ namespace osu.Game.Rulesets.Judgements /// /// A drawable object which visualises the hit result of a . /// - public class DrawableJudgement : CompositeDrawable + public class DrawableJudgement : PoolableDrawable { private const float judgement_size = 128; [Resolved] private OsuColour colours { get; set; } - protected readonly JudgementResult Result; + public JudgementResult Result { get; private set; } + public DrawableHitObject JudgedObject { get; private set; } - public readonly DrawableHitObject JudgedObject; + protected Container JudgementBody { get; private set; } + protected SpriteText JudgementText { get; private set; } - protected Container JudgementBody; - protected SpriteText JudgementText; + private SkinnableDrawable bodyDrawable; /// /// Duration of initial fade in. @@ -48,29 +52,21 @@ namespace osu.Game.Rulesets.Judgements /// The judgement to visualise. /// The object which was judged. public DrawableJudgement(JudgementResult result, DrawableHitObject judgedObject) + : this() { - Result = result; - JudgedObject = judgedObject; + Apply(result, judgedObject); + } + public DrawableJudgement() + { Size = new Vector2(judgement_size); + Origin = Anchor.Centre; } [BackgroundDependencyLoader] private void load() { - InternalChild = JudgementBody = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = new SkinnableDrawable(new GameplaySkinComponent(Result.Type), _ => JudgementText = new OsuSpriteText - { - Text = Result.Type.GetDescription().ToUpperInvariant(), - Font = OsuFont.Numeric.With(size: 20), - Colour = colours.ForHitResult(Result.Type), - Scale = new Vector2(0.85f, 1), - }, confineMode: ConfineMode.NoScaling) - }; + prepareDrawables(); } protected virtual void ApplyHitAnimations() @@ -81,11 +77,26 @@ namespace osu.Game.Rulesets.Judgements this.Delay(FadeOutDelay).FadeOut(400); } - protected override void LoadComplete() + public virtual void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) { - base.LoadComplete(); + Result = result; + JudgedObject = judgedObject; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Result != null); + + prepareDrawables(); + + bodyDrawable.ResetAnimation(); this.FadeInFromZero(FadeInDuration, Easing.OutQuint); + JudgementBody.ScaleTo(1); + JudgementBody.RotateTo(0); + JudgementBody.MoveTo(Vector2.Zero); switch (Result.Type) { @@ -109,5 +120,35 @@ namespace osu.Game.Rulesets.Judgements Expire(true); } + + private HitResult? currentDrawableType; + + private void prepareDrawables() + { + var type = Result?.Type ?? HitResult.Perfect; //TODO: better default type from ruleset + + if (type == currentDrawableType) + return; + + // sub-classes might have added their own children that would be removed here if .InternalChild was used. + if (JudgementBody != null) + RemoveInternal(JudgementBody); + + AddInternal(JudgementBody = new Container + { + 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) + }); + + currentDrawableType = type; + } } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index c3a8efdd66..165644edbe 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mods public BindableNumber DrainRate { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mods public BindableNumber OverallDifficulty { get; } = new BindableFloat { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, Default = 5, Value = 5, diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 35a8334237..6e94a84e7d 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -47,9 +47,25 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { Combo.BindTo(scoreProcessor.Combo); + + // Default value of ScoreProcessor's Rank in Flashlight Mod should be SS+ + scoreProcessor.Rank.Value = ScoreRank.XH; } - public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) + { + switch (rank) + { + case ScoreRank.X: + return ScoreRank.XH; + + case ScoreRank.S: + return ScoreRank.SH; + + default: + return rank; + } + } public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 7fe606d584..65f1a972ed 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -17,6 +17,7 @@ namespace osu.Game.Rulesets.Mods protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => !(result.Judgement is IgnoreJudgement) + && result.Judgement.AffectsCombo && result.Type != result.Judgement.MaxResult; } } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index cbd07efa97..839d97f04e 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -89,9 +89,9 @@ namespace osu.Game.Rulesets.Mods private void applyPitchAdjustment(ValueChangedEvent adjustPitchSetting) { // remove existing old adjustment - track.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); + track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); - track.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); + track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); } private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 44afb7a227..581617b567 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -126,12 +126,12 @@ namespace osu.Game.Rulesets.Objects.Drawables if (Result == null) throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - loadSamples(); + LoadSamples(); } - protected override void LoadComplete() + protected override void LoadAsyncComplete() { - base.LoadComplete(); + base.LoadAsyncComplete(); HitObject.DefaultsApplied += onDefaultsApplied; @@ -145,13 +145,19 @@ namespace osu.Game.Rulesets.Objects.Drawables } samplesBindable = HitObject.SamplesBindable.GetBoundCopy(); - samplesBindable.CollectionChanged += (_, __) => loadSamples(); + samplesBindable.CollectionChanged += (_, __) => LoadSamples(); - updateState(ArmedState.Idle, true); apply(HitObject); } - private void loadSamples() + protected override void LoadComplete() + { + base.LoadComplete(); + + updateState(ArmedState.Idle, true); + } + + protected virtual void LoadSamples() { if (Samples != null) { @@ -352,17 +358,32 @@ namespace osu.Game.Rulesets.Objects.Drawables [Resolved(canBeNull: true)] private GameplayClock gameplayClock { get; set; } + /// + /// Calculate the position to be used for sample playback at a specified X position (0..1). + /// + /// The lookup X position. Generally should be . + /// + protected double CalculateSamplePlaybackBalance(double position) + { + const float balance_adjust_amount = 0.4f; + + return balance_adjust_amount * (userPositionalHitSounds.Value ? position - 0.5f : 0); + } + + /// + /// Whether samples should currently be playing. Will be false during seek operations. + /// + protected bool ShouldPlaySamples => gameplayClock?.IsSeeking != true; + /// /// Plays all the hit sounds for this . /// This is invoked automatically when this is hit. /// public virtual void PlaySamples() { - const float balance_adjust_amount = 0.4f; - - if (Samples != null && gameplayClock?.IsSeeking != true) + if (Samples != null && ShouldPlaySamples) { - Samples.Balance.Value = balance_adjust_amount * (userPositionalHitSounds.Value ? SamplePlaybackPosition - 0.5f : 0); + Samples.Balance.Value = CalculateSamplePlaybackBalance(SamplePlaybackPosition); Samples.Play(); } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 77075b2abe..9afc0ecaf4 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -434,7 +434,7 @@ namespace osu.Game.Rulesets.Objects.Legacy /// /// /// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled - /// using the skin config option. + /// using the skin config option. /// public bool IsLayered { get; set; } } diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index 55d82c4083..cf5c88b8fd 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; -using osu.Framework.Input.StateChanges; using osu.Game.Input.Handlers; using osu.Game.Replays; @@ -69,8 +68,6 @@ namespace osu.Game.Rulesets.Replays return true; } - public override List GetPendingInputs() => new List(); - private const double sixty_frame_time = 1000.0 / 60; protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2; diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 4f28607733..3a7f433a37 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -23,6 +23,7 @@ using osu.Game.Scoring; using osu.Game.Skinning; using osu.Game.Users; using JetBrains.Annotations; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets { @@ -208,5 +209,14 @@ namespace osu.Game.Rulesets /// /// An empty frame for the current ruleset, or null if unsupported. public virtual IConvertibleReplayFrame CreateConvertibleReplayFrame() => null; + + /// + /// Creates the statistics for a to be displayed in the results screen. + /// + /// The to create the statistics for. The score is guaranteed to have populated. + /// The , converted for this with all relevant s applied. + /// The s to display. Each may contain 0 or more . + [NotNull] + public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 58a2ba056e..dd43092c0d 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -65,11 +65,15 @@ namespace osu.Game.Rulesets // the requesting assembly may be located out of the executable's base directory, thus requiring manual resolving of its dependencies. // this attempts resolving the ruleset dependencies on game core and framework assemblies by returning assemblies with the same assembly name // already loaded in the AppDomain. - foreach (var curAsm in AppDomain.CurrentDomain.GetAssemblies()) - { - if (asm.Name.Equals(curAsm.GetName().Name, StringComparison.Ordinal)) - return curAsm; - } + var domainAssembly = AppDomain.CurrentDomain.GetAssemblies() + // Given name is always going to be equally-or-more qualified than the assembly name. + .Where(a => args.Name.Contains(a.GetName().Name, StringComparison.Ordinal)) + // Pick the greatest assembly version. + .OrderByDescending(a => a.GetName().Version) + .FirstOrDefault(); + + if (domainAssembly != null) + return domainAssembly; return loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName); } diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index ef341575fa..130907b242 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -114,7 +114,9 @@ namespace osu.Game.Rulesets.Scoring protected override void ApplyResultInternal(JudgementResult result) { base.ApplyResultInternal(result); - healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); + + if (!result.Judgement.IsBonus) + healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); } protected override void Reset(bool storeResults) diff --git a/osu.Game/Rulesets/Scoring/HitEvent.cs b/osu.Game/Rulesets/Scoring/HitEvent.cs new file mode 100644 index 0000000000..0ebbec62ba --- /dev/null +++ b/osu.Game/Rulesets/Scoring/HitEvent.cs @@ -0,0 +1,66 @@ +// 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.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// A generated by the containing extra statistics around a . + /// + public readonly struct HitEvent + { + /// + /// The time offset from the end of at which the event occurred. + /// + public readonly double TimeOffset; + + /// + /// The hit result. + /// + public readonly HitResult Result; + + /// + /// The on which the result occurred. + /// + public readonly HitObject HitObject; + + /// + /// The occurring prior to . + /// + [CanBeNull] + public readonly HitObject LastHitObject; + + /// + /// A position, if available, at the time of the event. + /// + [CanBeNull] + public readonly Vector2? Position; + + /// + /// Creates a new . + /// + /// The time offset from the end of at which the event occurs. + /// The . + /// The that triggered the event. + /// The previous . + /// A position corresponding to the event. + public HitEvent(double timeOffset, HitResult result, HitObject hitObject, [CanBeNull] HitObject lastHitObject, [CanBeNull] Vector2? position) + { + TimeOffset = timeOffset; + Result = result; + HitObject = hitObject; + LastHitObject = lastHitObject; + Position = position; + } + + /// + /// Creates a new with an optional positional offset. + /// + /// The positional offset. + /// The new . + public HitEvent With(Vector2? positionOffset) => new HitEvent(TimeOffset, Result, HitObject, LastHitObject, positionOffset); + } +} diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 1f40f44dce..f1cdfd93c8 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -7,16 +7,16 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { public class ScoreProcessor : JudgementProcessor { - private const double base_portion = 0.3; - private const double combo_portion = 0.7; private const double max_score = 1000000; /// @@ -54,18 +54,37 @@ namespace osu.Game.Rulesets.Scoring /// public readonly Bindable Mode = new Bindable(); - private double maxHighestCombo; + /// + /// The default portion of awarded for hitting s accurately. Defaults to 30%. + /// + protected virtual double DefaultAccuracyPortion => 0.3; + /// + /// The default portion of awarded for achieving a high combo. Default to 70%. + /// + protected virtual double DefaultComboPortion => 0.7; + + private readonly double accuracyPortion; + private readonly double comboPortion; + + private double maxHighestCombo; private double maxBaseScore; private double rollingMaxBaseScore; private double baseScore; private double bonusScore; + private readonly List hitEvents = new List(); + private HitObject lastHitObject; + private double scoreMultiplier = 1; public ScoreProcessor() { - Debug.Assert(base_portion + combo_portion == 1.0); + accuracyPortion = DefaultAccuracyPortion; + comboPortion = DefaultComboPortion; + + if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) + throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => @@ -128,9 +147,20 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore += result.Judgement.MaxNumericResult; } + hitEvents.Add(CreateHitEvent(result)); + lastHitObject = result.HitObject; + updateScore(); } + /// + /// Creates the that describes a . + /// + /// The to describe. + /// The . + protected virtual HitEvent CreateHitEvent(JudgementResult result) + => new HitEvent(result.TimeOffset, result.Type, result.HitObject, lastHitObject, null); + protected sealed override void RevertResultInternal(JudgementResult result) { Combo.Value = result.ComboAtJudgement; @@ -153,6 +183,10 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore -= result.Judgement.MaxNumericResult; } + Debug.Assert(hitEvents.Count > 0); + lastHitObject = hitEvents[^1].LastHitObject; + hitEvents.RemoveAt(hitEvents.Count - 1); + updateScore(); } @@ -170,7 +204,10 @@ namespace osu.Game.Rulesets.Scoring { default: case ScoringMode.Standardised: - return (max_score * (base_portion * baseScore / maxBaseScore + combo_portion * HighestCombo.Value / maxHighestCombo) + bonusScore) * scoreMultiplier; + double accuracyScore = accuracyPortion * baseScore / maxBaseScore; + double comboScore = comboPortion * HighestCombo.Value / maxHighestCombo; + + return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; case ScoringMode.Classic: // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) @@ -207,6 +244,8 @@ namespace osu.Game.Rulesets.Scoring base.Reset(storeResults); scoreResultCounts.Clear(); + hitEvents.Clear(); + lastHitObject = null; if (storeResults) { @@ -231,6 +270,12 @@ namespace osu.Game.Rulesets.Scoring HighestCombo.Value = 0; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + hitEvents.Clear(); + } + /// /// Retrieve a score populated with data for the current play this processor is responsible for. /// @@ -247,6 +292,8 @@ namespace osu.Game.Rulesets.Scoring foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) score.Statistics[result] = GetStatistic(result); + + score.HitEvents = hitEvents; } /// diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 168e937256..83a1077d70 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -117,6 +117,8 @@ namespace osu.Game.Rulesets.UI public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotSupportedException(); + public BindableNumber Volume => throw new NotSupportedException(); public BindableNumber Balance => throw new NotSupportedException(); diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index ba30fe28d5..f2ac61eaf4 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -18,9 +18,6 @@ using osu.Game.Input.Handlers; using osu.Game.Screens.Play; using osuTK.Input; using static osu.Game.Input.Handlers.ReplayInputHandler; -using JoystickState = osu.Framework.Input.States.JoystickState; -using KeyboardState = osu.Framework.Input.States.KeyboardState; -using MouseState = osu.Framework.Input.States.MouseState; namespace osu.Game.Rulesets.UI { @@ -42,11 +39,7 @@ namespace osu.Game.Rulesets.UI } } - protected override InputState CreateInitialState() - { - var state = base.CreateInitialState(); - return new RulesetInputManagerInputState(state.Mouse, state.Keyboard, state.Joystick); - } + protected override InputState CreateInitialState() => new RulesetInputManagerInputState(base.CreateInitialState()); protected readonly KeyBindingContainer KeyBindingContainer; @@ -203,8 +196,8 @@ namespace osu.Game.Rulesets.UI { public ReplayState LastReplayState; - public RulesetInputManagerInputState(MouseState mouse = null, KeyboardState keyboard = null, JoystickState joystick = null) - : base(mouse, keyboard, joystick) + public RulesetInputManagerInputState(InputState state = null) + : base(state) { } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs index 0052c877f6..a1f68d7201 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs @@ -3,21 +3,26 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using JetBrains.Annotations; using osu.Game.Rulesets.Timing; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class SequentialScrollAlgorithm : IScrollAlgorithm { - private readonly Dictionary positionCache; + private static readonly IComparer by_position_comparer = Comparer.Create((c1, c2) => c1.Position.CompareTo(c2.Position)); private readonly IReadOnlyList controlPoints; + /// + /// Stores a mapping of time -> position for each control point. + /// + private readonly List positionMappings = new List(); + public SequentialScrollAlgorithm(IReadOnlyList controlPoints) { this.controlPoints = controlPoints; - - positionCache = new Dictionary(); } public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) @@ -27,55 +32,31 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) { - var objectLength = relativePositionAtCached(endTime, timeRange) - relativePositionAtCached(startTime, timeRange); + var objectLength = relativePositionAt(endTime, timeRange) - relativePositionAt(startTime, timeRange); return (float)(objectLength * scrollLength); } public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) { - // Caching is not used here as currentTime is unlikely to have been previously cached - double timelinePosition = relativePositionAt(currentTime, timeRange); - return (float)((relativePositionAtCached(time, timeRange) - timelinePosition) * scrollLength); + double timelineLength = relativePositionAt(time, timeRange) - relativePositionAt(currentTime, timeRange); + return (float)(timelineLength * scrollLength); } public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { - // Convert the position to a length relative to time = 0 - double length = position / scrollLength + relativePositionAt(currentTime, timeRange); + if (controlPoints.Count == 0) + return position * timeRange; - // We need to consider all timing points until the specified time and not just the currently-active one, - // since each timing point individually affects the positions of _all_ hitobjects after its start time - for (int i = 0; i < controlPoints.Count; i++) - { - var current = controlPoints[i]; - var next = i < controlPoints.Count - 1 ? controlPoints[i + 1] : null; + // Find the position at the current time, and the given length. + double relativePosition = relativePositionAt(currentTime, timeRange) + position / scrollLength; - // Duration of the current control point - var currentDuration = (next?.StartTime ?? double.PositiveInfinity) - current.StartTime; + var positionMapping = findControlPointMapping(timeRange, new PositionMapping(0, null, relativePosition), by_position_comparer); - // Figure out the length of control point - var currentLength = currentDuration / timeRange * current.Multiplier; - - if (currentLength > length) - { - // The point is within this control point - return current.StartTime + length * timeRange / current.Multiplier; - } - - length -= currentLength; - } - - return 0; // Should never occur + // Begin at the control point's time and add the remaining time to reach the given position. + return positionMapping.Time + (relativePosition - positionMapping.Position) * timeRange / positionMapping.ControlPoint.Multiplier; } - private double relativePositionAtCached(double time, double timeRange) - { - if (!positionCache.TryGetValue(time, out double existing)) - positionCache[time] = existing = relativePositionAt(time, timeRange); - return existing; - } - - public void Reset() => positionCache.Clear(); + public void Reset() => positionMappings.Clear(); /// /// Finds the position which corresponds to a point in time. @@ -84,37 +65,100 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The time to find the position at. /// The amount of time visualised by the scrolling area. /// A positive value indicating the position at . - private double relativePositionAt(double time, double timeRange) + private double relativePositionAt(in double time, in double timeRange) { if (controlPoints.Count == 0) return time / timeRange; - double length = 0; + var mapping = findControlPointMapping(timeRange, new PositionMapping(time)); - // We need to consider all timing points until the specified time and not just the currently-active one, - // since each timing point individually affects the positions of _all_ hitobjects after its start time - for (int i = 0; i < controlPoints.Count; i++) + // Begin at the control point's position and add the remaining distance to reach the given time. + return mapping.Position + (time - mapping.Time) / timeRange * mapping.ControlPoint.Multiplier; + } + + /// + /// Finds a 's that is relevant to a given . + /// + /// + /// This is used to find the last occuring prior to a time value, or prior to a position value (if is used). + /// + /// The time range. + /// The to find the closest to. + /// The comparison. If null, the default comparer is used (by time). + /// The 's that is relevant for . + private PositionMapping findControlPointMapping(in double timeRange, in PositionMapping search, IComparer comparer = null) + { + generatePositionMappings(timeRange); + + var mappingIndex = positionMappings.BinarySearch(search, comparer ?? Comparer.Default); + + if (mappingIndex < 0) { - var current = controlPoints[i]; - var next = i < controlPoints.Count - 1 ? controlPoints[i + 1] : null; + // If the search value isn't found, the _next_ control point is returned, but we actually want the _previous_ control point. + // In doing so, we must make sure to not underflow the position mapping list (i.e. always use the 0th control point for time < first_control_point_time). + mappingIndex = Math.Max(0, ~mappingIndex - 1); - // We don't need to consider any control points beyond the current time, since it will not yet - // affect any hitobjects - if (i > 0 && current.StartTime > time) - continue; - - // Duration of the current control point - var currentDuration = (next?.StartTime ?? double.PositiveInfinity) - current.StartTime; - - // We want to consider the minimal amount of time that this control point has affected, - // which may be either its duration, or the amount of time that has passed within it - var durationInCurrent = Math.Min(currentDuration, time - current.StartTime); - - // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier - length += durationInCurrent / timeRange * current.Multiplier; + Debug.Assert(mappingIndex < positionMappings.Count); } - return length; + var mapping = positionMappings[mappingIndex]; + Debug.Assert(mapping.ControlPoint != null); + + return mapping; + } + + /// + /// Generates the mapping of (and their respective start times) to their relative position from 0. + /// + /// The time range. + private void generatePositionMappings(in double timeRange) + { + if (positionMappings.Count > 0) + return; + + if (controlPoints.Count == 0) + return; + + positionMappings.Add(new PositionMapping(controlPoints[0].StartTime, controlPoints[0])); + + for (int i = 0; i < controlPoints.Count - 1; i++) + { + var current = controlPoints[i]; + var next = controlPoints[i + 1]; + + // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier + float length = (float)((next.StartTime - current.StartTime) / timeRange * current.Multiplier); + + positionMappings.Add(new PositionMapping(next.StartTime, next, positionMappings[^1].Position + length)); + } + } + + private readonly struct PositionMapping : IComparable + { + /// + /// The time corresponding to this position. + /// + public readonly double Time; + + /// + /// The at . + /// + [CanBeNull] + public readonly MultiplierControlPoint ControlPoint; + + /// + /// The relative position from 0 of . + /// + public readonly double Position; + + public PositionMapping(double time, MultiplierControlPoint controlPoint = null, double position = default) + { + Time = time; + ControlPoint = controlPoint; + Position = position; + } + + public int CompareTo(PositionMapping other) => Time.CompareTo(other.Time); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 0dc3324559..bf64175468 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Layout; +using osu.Framework.Threading; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -17,7 +19,7 @@ namespace osu.Game.Rulesets.UI.Scrolling { private readonly IBindable timeRange = new BindableDouble(); private readonly IBindable direction = new Bindable(); - private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); + private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); [Resolved] private IScrollingInfo scrollingInfo { get; set; } @@ -175,10 +177,10 @@ namespace osu.Game.Rulesets.UI.Scrolling { // The cache may not exist if the hitobject state hasn't been computed yet (e.g. if the hitobject was added + defaults applied in the same frame). // In such a case, combinedObjCache will take care of updating the hitobject. - if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var objCache)) + if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var state)) { combinedObjCache.Invalidate(); - objCache.Invalidate(); + state.Cache.Invalidate(); } } @@ -190,8 +192,8 @@ namespace osu.Game.Rulesets.UI.Scrolling if (!layoutCache.IsValid) { - foreach (var cached in hitObjectInitialStateCache.Values) - cached.Invalidate(); + foreach (var state in hitObjectInitialStateCache.Values) + state.Cache.Invalidate(); combinedObjCache.Invalidate(); scrollingInfo.Algorithm.Reset(); @@ -215,16 +217,18 @@ namespace osu.Game.Rulesets.UI.Scrolling foreach (var obj in Objects) { - if (!hitObjectInitialStateCache.TryGetValue(obj, out var objCache)) - objCache = hitObjectInitialStateCache[obj] = new Cached(); + if (!hitObjectInitialStateCache.TryGetValue(obj, out var state)) + state = hitObjectInitialStateCache[obj] = new InitialState(new Cached()); - if (objCache.IsValid) + if (state.Cache.IsValid) continue; - computeLifetimeStartRecursive(obj); - computeInitialStateRecursive(obj); + state.ScheduledComputation?.Cancel(); + state.ScheduledComputation = computeInitialStateRecursive(obj); - objCache.Validate(); + computeLifetimeStartRecursive(obj); + + state.Cache.Validate(); } combinedObjCache.Validate(); @@ -267,8 +271,7 @@ namespace osu.Game.Rulesets.UI.Scrolling return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); } - // Cant use AddOnce() since the delegate is re-constructed every invocation - private void computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => + private ScheduledDelegate computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => { if (hitObject.HitObject is IHasDuration e) { @@ -325,5 +328,19 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } } + + private class InitialState + { + [NotNull] + public readonly Cached Cache; + + [CanBeNull] + public ScheduledDelegate ScheduledComputation; + + public InitialState(Cached cache) + { + Cache = cache; + } + } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 7b37c267bc..efcf1737c9 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -166,6 +166,10 @@ namespace osu.Game.Scoring } } + [NotMapped] + [JsonIgnore] + public List HitEvents { get; set; } + [JsonIgnore] public List Files { get; set; } @@ -175,6 +179,13 @@ namespace osu.Game.Scoring [JsonIgnore] public bool DeletePending { get; set; } + /// + /// The position of this score, starting at 1. + /// + [NotMapped] + [JsonProperty("position")] + public int? Position { get; set; } + [Serializable] protected class DeserializedMod : IMod { diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 980a127cf4..ef41c5be3d 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -21,12 +21,10 @@ namespace osu.Game.Screens.Backgrounds private int currentDisplay; private const int background_count = 7; - - private string backgroundName => $@"Menu/menu-background-{currentDisplay % background_count + 1}"; - private Bindable user; private Bindable skin; private Bindable mode; + private Bindable introSequence; [Resolved] private IBindable beatmap { get; set; } @@ -42,11 +40,13 @@ namespace osu.Game.Screens.Backgrounds user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); mode = config.GetBindable(OsuSetting.MenuBackgroundSource); + introSequence = config.GetBindable(OsuSetting.IntroSequence); user.ValueChanged += _ => Next(); skin.ValueChanged += _ => Next(); mode.ValueChanged += _ => Next(); beatmap.ValueChanged += _ => Next(); + introSequence.ValueChanged += _ => Next(); currentDisplay = RNG.Next(0, background_count); @@ -73,6 +73,18 @@ namespace osu.Game.Screens.Backgrounds private Background createBackground() { Background newBackground; + string backgroundName; + + switch (introSequence.Value) + { + case IntroSequence.Welcome: + backgroundName = "Intro/Welcome/menu-background"; + break; + + default: + backgroundName = $@"Menu/menu-background-{currentDisplay % background_count + 1}"; + break; + } if (user.Value?.IsSupporter ?? false) { diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index c6e228262f..fcff672045 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -64,6 +64,7 @@ namespace osu.Game.Screens.Edit.Compose.Components DragBox = CreateDragBox(select), selectionHandler, SelectionBlueprints = CreateSelectionBlueprintContainer(), + selectionHandler.CreateProxy(), DragBox.CreateProxy().With(p => p.Depth = float.MinValue) }); @@ -121,14 +122,19 @@ namespace osu.Game.Screens.Edit.Compose.Components return e.Button == MouseButton.Left; } + private SelectionBlueprint clickedBlueprint; + protected override bool OnClick(ClickEvent e) { if (e.Button == MouseButton.Right) return false; + // store for double-click handling + clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); + // Deselection should only occur if no selected blueprints are hovered // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection - if (endClickSelection() || selectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) + if (endClickSelection() || clickedBlueprint != null) return true; deselectAll(); @@ -140,9 +146,8 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.Button == MouseButton.Right) return false; - SelectionBlueprint clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); - - if (clickedBlueprint == null) + // ensure the blueprint which was hovered for the first click is still the hovered blueprint. + if (clickedBlueprint == null || selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint) return false; editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 38893f90a8..9700cb8c8e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -15,6 +15,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.States; using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -35,7 +36,9 @@ namespace osu.Game.Screens.Edit.Compose.Components public IEnumerable SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject); - private Drawable outline; + private Drawable content; + + private OsuSpriteText selectionDetailsText; [Resolved(CanBeNull = true)] protected EditorBeatmap EditorBeatmap { get; private set; } @@ -55,16 +58,42 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - InternalChild = outline = new Container + InternalChild = content = new Container { - Masking = true, - BorderThickness = BORDER_RADIUS, - BorderColour = colours.Yellow, - Child = new Box + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - AlwaysPresent = true, - Alpha = 0 + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = BORDER_RADIUS, + BorderColour = colours.YellowDark, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0 + } + }, + new Container + { + Name = "info text", + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + }, + selectionDetailsText = new OsuSpriteText + { + Padding = new MarginPadding(2), + Colour = colours.Gray0, + Font = OsuFont.Default.With(size: 11) + } + } + } } }; } @@ -131,9 +160,7 @@ namespace osu.Game.Screens.Edit.Compose.Components selectedBlueprints.Remove(blueprint); EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject); - // We don't want to update visibility if > 0, since we may be deselecting blueprints during drag-selection - if (selectedBlueprints.Count == 0) - UpdateVisibility(); + UpdateVisibility(); } /// @@ -179,7 +206,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// internal void UpdateVisibility() { - if (selectedBlueprints.Count > 0) + int count = selectedBlueprints.Count; + + selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty; + + if (count > 0) Show(); else Hide(); @@ -205,8 +236,8 @@ namespace osu.Game.Screens.Edit.Compose.Components topLeft -= new Vector2(5); bottomRight += new Vector2(5); - outline.Size = bottomRight - topLeft; - outline.Position = topLeft; + content.Size = bottomRight - topLeft; + content.Position = topLeft; } #endregion diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 9f61589c36..d92f3922c3 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -275,11 +275,22 @@ namespace osu.Game.Screens.Edit protected override bool OnScroll(ScrollEvent e) { - scrollAccumulation += (e.ScrollDelta.X + e.ScrollDelta.Y) * (e.IsPrecise ? 0.1 : 1); + const double precision = 1; - const int precision = 1; + double scrollComponent = e.ScrollDelta.X + e.ScrollDelta.Y; - while (Math.Abs(scrollAccumulation) > precision) + double scrollDirection = Math.Sign(scrollComponent); + + // this is a special case to handle the "pivot" scenario. + // if we are precise scrolling in one direction then change our mind and scroll backwards, + // the existing accumulation should be applied in the inverse direction to maintain responsiveness. + if (Math.Sign(scrollAccumulation) != scrollDirection) + scrollAccumulation = scrollDirection * (precision - Math.Abs(scrollAccumulation)); + + scrollAccumulation += scrollComponent * (e.IsPrecise ? 0.1 : 1); + + // because we are doing snapped seeking, we need to add up precise scrolls until they accumulate to an arbitrary cut-off. + while (Math.Abs(scrollAccumulation) >= precision) { if (scrollAccumulation > 0) seek(e, -1); diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index dd934c10cd..d4d0feb813 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -118,9 +118,14 @@ namespace osu.Game.Screens.Edit seekTime = timingPoint.Time + closestBeat * seekAmount; + // limit forward seeking to only up to the next timing point's start time. + var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); + if (seekTime > nextTimingPoint?.Time) + seekTime = nextTimingPoint.Time; + // Due to the rounding above, we may end up on the current beat. This will effectively cause 0 seeking to happen, but we don't want this. // Instead, we'll go to the next beat in the direction when this is the case - if (Precision.AlmostEquals(current, seekTime)) + if (Precision.AlmostEquals(current, seekTime, 0.5f)) { closestBeat += direction > 0 ? 1 : -1; seekTime = timingPoint.Time + closestBeat * seekAmount; @@ -129,10 +134,6 @@ namespace osu.Game.Screens.Edit if (seekTime < timingPoint.Time && timingPoint != ControlPointInfo.TimingPoints.First()) seekTime = timingPoint.Time; - var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); - if (seekTime > nextTimingPoint?.Time) - seekTime = nextTimingPoint.Time; - // Ensure the sought point is within the boundaries seekTime = Math.Clamp(seekTime, 0, TrackLength); SeekTo(seekTime); diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 22fe0ad816..761f842c22 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -56,5 +56,14 @@ namespace osu.Game.Screens /// Whether mod rate adjustments are allowed to be applied. /// bool AllowRateAdjustments { get; } + + /// + /// Invoked when the back button has been pressed to close any overlays before exiting this . + /// + /// + /// Return true to block this from being exited after closing an overlay. + /// Return false if this should continue exiting. + /// + bool OnBackButton(); } } diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs new file mode 100644 index 0000000000..d120eb21a8 --- /dev/null +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class ConfirmExitDialog : PopupDialog + { + public ConfirmExitDialog(Action confirm, Action cancel) + { + HeaderText = "Are you sure you want to exit?"; + BodyText = "Last chance to back out."; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Goodbye", + Action = confirm + }, + new PopupDialogCancelButton + { + Text = @"Just a little more", + Action = cancel + }, + }; + } + } +} diff --git a/osu.Game/Screens/Menu/Disclaimer.cs b/osu.Game/Screens/Menu/Disclaimer.cs index 35091028ae..986de1edf0 100644 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ b/osu.Game/Screens/Menu/Disclaimer.cs @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Menu { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = FontAwesome.Solid.Poo, + Icon = FontAwesome.Solid.Flask, Size = new Vector2(icon_size), Y = icon_y, }, diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index 88d18d0073..5f91aaad15 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -73,7 +73,6 @@ namespace osu.Game.Screens.Menu MenuVoice = config.GetBindable(OsuSetting.MenuVoice); MenuMusic = config.GetBindable(OsuSetting.MenuMusic); - seeya = audio.Samples.Get(SeeyaSampleName); BeatmapSetInfo setInfo = null; diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index 9be74a0fd9..a9ef20436f 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.IO; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -12,7 +11,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Video; using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Graphics; @@ -88,7 +86,7 @@ namespace osu.Game.Screens.Menu private RulesetFlow rulesets; private Container rulesetsScale; private Container logoContainerSecondary; - private Drawable lazerLogo; + private LazerLogo lazerLogo; private GlitchingTriangles triangles; @@ -139,10 +137,10 @@ namespace osu.Game.Screens.Menu RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = lazerLogo = new LazerLogo(textures.GetStream("Menu/logo-triangles.mp4")) + Child = lazerLogo = new LazerLogo { Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = Anchor.Centre } }, }; @@ -218,6 +216,9 @@ namespace osu.Game.Screens.Menu // matching flyte curve y = 0.25x^2 + (max(0, x - 0.7) / 0.3) ^ 5 lazerLogo.FadeIn().ScaleTo(scale_start).Then().Delay(logo_scale_duration * 0.7f).ScaleTo(scale_start - scale_adjust, logo_scale_duration * 0.3f, Easing.InQuint); + + lazerLogo.TransformTo(nameof(LazerLogo.Progress), 1f, logo_scale_duration); + logoContainerSecondary.ScaleTo(scale_start).Then().ScaleTo(scale_start - scale_adjust * 0.25f, logo_scale_duration, Easing.InQuad); } @@ -259,20 +260,40 @@ namespace osu.Game.Screens.Menu private class LazerLogo : CompositeDrawable { - private readonly Stream videoStream; + private LogoAnimation highlight, background; - public LazerLogo(Stream videoStream) + public float Progress + { + get => background.AnimationProgress; + set + { + background.AnimationProgress = value; + highlight.AnimationProgress = value; + } + } + + public LazerLogo() { - this.videoStream = videoStream; Size = new Vector2(960); } [BackgroundDependencyLoader] - private void load() + private void load(TextureStore textures) { - InternalChild = new Video(videoStream) + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.Both, + highlight = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-highlight"), + Colour = Color4.White, + }, + background = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }, }; } } diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index abd4a68d4f..bf42e36e8c 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -39,6 +39,8 @@ namespace osu.Game.Screens.Menu welcome = audio.Samples.Get(@"Intro/Welcome/welcome"); pianoReverb = audio.Samples.Get(@"Intro/Welcome/welcome_piano"); + + Track.Looping = true; } protected override void LogoArriving(OsuLogo logo, bool resuming) diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index f0da2482d6..57252d557e 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -1,22 +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 System; using System.Linq; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.IO; using osu.Game.Online.API; using osu.Game.Overlays; -using osu.Game.Overlays.Dialog; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.Multi; @@ -63,6 +62,8 @@ namespace osu.Game.Screens.Menu protected override BackgroundScreen CreateBackground() => background; + internal Track Track { get; private set; } + private Bindable holdDelay; private Bindable loginDisplayed; @@ -168,22 +169,28 @@ namespace osu.Game.Screens.Menu return s; } + [Resolved] + private Storage storage { get; set; } + public override void OnEntering(IScreen last) { base.OnEntering(last); buttons.FadeInFromZero(500); - var track = Beatmap.Value.Track; + Track = Beatmap.Value.Track; var metadata = Beatmap.Value.Metadata; - if (last is IntroScreen && track != null) + if (last is IntroScreen && Track != null) { - if (!track.IsRunning) + if (!Track.IsRunning) { - track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * track.Length); - track.Start(); + Track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * Track.Length); + Track.Start(); } } + + if (storage is OsuStorage osuStorage && osuStorage.Error != OsuStorageError.None) + dialogOverlay?.Push(new StorageErrorDialog(osuStorage, osuStorage.Error)); } private bool exitConfirmed; @@ -253,8 +260,7 @@ namespace osu.Game.Screens.Menu // we may have consumed our preloaded instance, so let's make another. preloadSongSelect(); - if (Beatmap.Value.Track != null && music?.IsUserPaused != true) - Beatmap.Value.Track.Start(); + music.EnsurePlayingSomething(); } public override bool OnExiting(IScreen next) @@ -280,30 +286,5 @@ namespace osu.Game.Screens.Menu this.FadeOut(3000); return base.OnExiting(next); } - - private class ConfirmExitDialog : PopupDialog - { - public ConfirmExitDialog(Action confirm, Action cancel) - { - HeaderText = "Are you sure you want to exit?"; - BodyText = "Last chance to back out."; - - Icon = FontAwesome.Solid.ExclamationTriangle; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Goodbye", - Action = confirm - }, - new PopupDialogCancelButton - { - Text = @"Just a little more", - Action = cancel - }, - }; - } - } } } diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 089906c342..f5e4b078da 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -330,7 +330,7 @@ namespace osu.Game.Screens.Menu if (Beatmap.Value.Track.IsRunning) { var maxAmplitude = lastBeatIndex >= 0 ? Beatmap.Value.Track.CurrentAmplitudes.Maximum : 0; - logoAmplitudeContainer.ScaleTo(1 - Math.Max(0, maxAmplitude - scale_adjust_cutoff) * 0.04f, 75, Easing.OutQuint); + logoAmplitudeContainer.Scale = new Vector2((float)Interpolation.Damp(logoAmplitudeContainer.Scale.X, 1 - Math.Max(0, maxAmplitude - scale_adjust_cutoff) * 0.04f, 0.9f, Time.Elapsed)); if (maxAmplitude > velocity_adjust_cutoff) triangles.Velocity = 1 + Math.Max(0, maxAmplitude - velocity_adjust_cutoff) * 50; diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs new file mode 100644 index 0000000000..dcaad4013a --- /dev/null +++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.IO; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class StorageErrorDialog : PopupDialog + { + [Resolved] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved] + private OsuGameBase osuGame { get; set; } + + public StorageErrorDialog(OsuStorage storage, OsuStorageError error) + { + HeaderText = "osu! storage error"; + Icon = FontAwesome.Solid.ExclamationTriangle; + + var buttons = new List(); + + switch (error) + { + case OsuStorageError.NotAccessible: + BodyText = $"The specified osu! data location (\"{storage.CustomStoragePath}\") is not accessible. If it is on external storage, please reconnect the device and try again."; + + buttons.AddRange(new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = "Try again", + Action = () => + { + if (!storage.TryChangeToCustomStorage(out var nextError)) + dialogOverlay.Push(new StorageErrorDialog(storage, nextError)); + } + }, + new PopupDialogCancelButton + { + Text = "Use default location until restart", + }, + new PopupDialogOkButton + { + Text = "Reset to default location", + Action = storage.ResetCustomStoragePath + }, + }); + break; + + case OsuStorageError.AccessibleButEmpty: + BodyText = $"The specified osu! data location (\"{storage.CustomStoragePath}\") is empty. If you have moved the files, please close osu! and move them back."; + + // Todo: Provide the option to search for the files similar to migration. + buttons.AddRange(new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = "Start fresh at specified location" + }, + new PopupDialogOkButton + { + Text = "Reset to default location", + Action = storage.ResetCustomStoragePath + }, + }); + + break; + } + + Buttons = buttons; + } + } +} diff --git a/osu.Game/Screens/Multi/Components/OverlinedDisplay.cs b/osu.Game/Screens/Multi/Components/OverlinedDisplay.cs deleted file mode 100644 index 8d8d4cc404..0000000000 --- a/osu.Game/Screens/Multi/Components/OverlinedDisplay.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK; - -namespace osu.Game.Screens.Multi.Components -{ - public abstract class OverlinedDisplay : MultiplayerComposite - { - protected readonly Container Content; - - public override Axes RelativeSizeAxes - { - get => base.RelativeSizeAxes; - set - { - base.RelativeSizeAxes = value; - updateDimensions(); - } - } - - public override Axes AutoSizeAxes - { - get => base.AutoSizeAxes; - protected set - { - base.AutoSizeAxes = value; - updateDimensions(); - } - } - - protected string Details - { - set => details.Text = value; - } - - private readonly Circle line; - private readonly OsuSpriteText details; - private readonly GridContainer grid; - - protected OverlinedDisplay(string title) - { - InternalChild = grid = new GridContainer - { - Content = new[] - { - new Drawable[] - { - line = new Circle - { - RelativeSizeAxes = Axes.X, - Height = 2, - Margin = new MarginPadding { Bottom = 2 } - }, - }, - new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Top = 5 }, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new OsuSpriteText - { - Text = title, - Font = OsuFont.GetFont(size: 14) - }, - details = new OsuSpriteText { Font = OsuFont.GetFont(size: 14) }, - } - }, - }, - new Drawable[] - { - Content = new Container { Padding = new MarginPadding { Top = 5 } } - } - } - }; - - updateDimensions(); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - line.Colour = colours.Yellow; - details.Colour = colours.Yellow; - } - - private void updateDimensions() - { - grid.RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(AutoSizeAxes.HasFlag(Axes.Y) ? GridSizeMode.AutoSize : GridSizeMode.Distributed), - }; - - // Assigning to none is done so that setting auto and relative size modes doesn't cause exceptions to be thrown - grid.AutoSizeAxes = Content.AutoSizeAxes = Axes.None; - grid.RelativeSizeAxes = Content.RelativeSizeAxes = Axes.None; - - // Auto-size when required, otherwise eagerly relative-size - grid.AutoSizeAxes = Content.AutoSizeAxes = AutoSizeAxes; - grid.RelativeSizeAxes = Content.RelativeSizeAxes = ~AutoSizeAxes; - } - } -} diff --git a/osu.Game/Screens/Multi/Components/OverlinedHeader.cs b/osu.Game/Screens/Multi/Components/OverlinedHeader.cs new file mode 100644 index 0000000000..7ec20c8cae --- /dev/null +++ b/osu.Game/Screens/Multi/Components/OverlinedHeader.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + /// + /// A header used in the multiplayer interface which shows text / details beneath a line. + /// + public class OverlinedHeader : MultiplayerComposite + { + private bool showLine = true; + + public bool ShowLine + { + get => showLine; + set + { + showLine = value; + line.Alpha = value ? 1 : 0; + } + } + + public Bindable Details = new Bindable(); + + private readonly Circle line; + private readonly OsuSpriteText details; + + public OverlinedHeader(string title) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Margin = new MarginPadding { Bottom = 5 }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + line = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 2, + Margin = new MarginPadding { Bottom = 2 } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Top = 5 }, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = title, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }, + details = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }, + } + }, + } + }; + + Details.BindValueChanged(val => details.Text = val.NewValue); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + line.Colour = colours.Yellow; + details.Colour = colours.Yellow; + } + } +} diff --git a/osu.Game/Screens/Multi/Components/OverlinedPlaylist.cs b/osu.Game/Screens/Multi/Components/OverlinedPlaylist.cs deleted file mode 100644 index 4fe79b40a0..0000000000 --- a/osu.Game/Screens/Multi/Components/OverlinedPlaylist.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Online.Multiplayer; - -namespace osu.Game.Screens.Multi.Components -{ - public class OverlinedPlaylist : OverlinedDisplay - { - public readonly Bindable SelectedItem = new Bindable(); - - private readonly DrawableRoomPlaylist playlist; - - public OverlinedPlaylist(bool allowSelection) - : base("Playlist") - { - Content.Add(playlist = new DrawableRoomPlaylist(false, allowSelection) - { - RelativeSizeAxes = Axes.Both, - SelectedItem = { BindTarget = SelectedItem } - }); - } - - [BackgroundDependencyLoader] - private void load() - { - playlist.Items.BindTo(Playlist); - } - } -} diff --git a/osu.Game/Screens/Multi/Components/OverlinedParticipants.cs b/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs similarity index 63% rename from osu.Game/Screens/Multi/Components/OverlinedParticipants.cs rename to osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs index eb1782d147..6ea4283379 100644 --- a/osu.Game/Screens/Multi/Components/OverlinedParticipants.cs +++ b/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs @@ -2,26 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Multi.Components { - public class OverlinedParticipants : OverlinedDisplay + public class ParticipantsDisplay : MultiplayerComposite { - public new Axes AutoSizeAxes - { - get => base.AutoSizeAxes; - set => base.AutoSizeAxes = value; - } + public Bindable Details = new Bindable(); - public OverlinedParticipants(Direction direction) - : base("Recent participants") + public ParticipantsDisplay(Direction direction) { OsuScrollContainer scroll; ParticipantsList list; - Content.Add(scroll = new OsuScrollContainer(direction) + AddInternal(scroll = new OsuScrollContainer(direction) { Child = list = new ParticipantsList() }); @@ -29,13 +25,21 @@ namespace osu.Game.Screens.Multi.Components switch (direction) { case Direction.Horizontal: + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + scroll.RelativeSizeAxes = Axes.X; scroll.Height = ParticipantsList.TILE_SIZE + OsuScrollContainer.SCROLL_BAR_HEIGHT + OsuScrollContainer.SCROLL_BAR_PADDING * 2; - list.AutoSizeAxes = Axes.Both; + + list.RelativeSizeAxes = Axes.Y; + list.AutoSizeAxes = Axes.X; break; case Direction.Vertical: + RelativeSizeAxes = Axes.Both; + scroll.RelativeSizeAxes = Axes.Both; + list.RelativeSizeAxes = Axes.X; list.AutoSizeAxes = Axes.Y; break; @@ -46,11 +50,10 @@ namespace osu.Game.Screens.Multi.Components private void load() { ParticipantCount.BindValueChanged(_ => setParticipantCount()); - MaxParticipants.BindValueChanged(_ => setParticipantCount()); - - setParticipantCount(); + MaxParticipants.BindValueChanged(_ => setParticipantCount(), true); } - private void setParticipantCount() => Details = MaxParticipants.Value != null ? $"{ParticipantCount.Value}/{MaxParticipants.Value}" : ParticipantCount.Value.ToString(); + private void setParticipantCount() => + Details.Value = MaxParticipants.Value != null ? $"{ParticipantCount.Value}/{MaxParticipants.Value}" : ParticipantCount.Value.ToString(); } } diff --git a/osu.Game/Screens/Multi/Components/ParticipantsList.cs b/osu.Game/Screens/Multi/Components/ParticipantsList.cs index 79d130adf5..7978b4eaab 100644 --- a/osu.Game/Screens/Multi/Components/ParticipantsList.cs +++ b/osu.Game/Screens/Multi/Components/ParticipantsList.cs @@ -79,7 +79,7 @@ namespace osu.Game.Screens.Multi.Components Direction = Direction, AutoSizeAxes = AutoSizeAxes, RelativeSizeAxes = RelativeSizeAxes, - Spacing = new Vector2(10) + Spacing = Vector2.One }; for (int i = 0; i < RecentParticipants.Count; i++) diff --git a/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs b/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs index 97af6674bf..a115f06e7b 100644 --- a/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs @@ -17,6 +17,9 @@ namespace osu.Game.Screens.Multi.Components [Resolved(typeof(Room), nameof(Room.Status))] private Bindable status { get; set; } + [Resolved(typeof(Room), nameof(Room.Category))] + private Bindable category { get; set; } + public StatusColouredContainer(double transitionDuration = 100) { this.transitionDuration = transitionDuration; @@ -25,7 +28,11 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - status.BindValueChanged(s => this.FadeColour(s.NewValue.GetAppropriateColour(colours), transitionDuration), true); + status.BindValueChanged(s => + { + this.FadeColour(category.Value == RoomCategory.Spotlight ? colours.Pink : s.NewValue.GetAppropriateColour(colours) + , transitionDuration); + }, true); } } } diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs index 9a3fcb1cdc..89c335183b 100644 --- a/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs @@ -60,8 +60,6 @@ namespace osu.Game.Screens.Multi RequestDeletion = requestDeletion }; - private void requestSelection(PlaylistItem item) => SelectedItem.Value = item; - private void requestDeletion(PlaylistItem item) { if (SelectedItem.Value == item) diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs index 414c1f5748..c0892235f2 100644 --- a/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -48,7 +49,8 @@ namespace osu.Game.Screens.Multi private readonly Bindable ruleset = new Bindable(); private readonly BindableList requiredMods = new BindableList(); - private readonly PlaylistItem item; + public readonly PlaylistItem Item; + private readonly bool allowEdit; private readonly bool allowSelection; @@ -57,8 +59,11 @@ namespace osu.Game.Screens.Multi public DrawableRoomPlaylistItem(PlaylistItem item, bool allowEdit, bool allowSelection) : base(item) { - this.item = item; + Item = item; + + // TODO: edit support should be moved out into a derived class this.allowEdit = allowEdit; + this.allowSelection = allowSelection; beatmap.BindTo(item.Beatmap); @@ -102,14 +107,14 @@ namespace osu.Game.Screens.Multi difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value) { Size = new Vector2(32) }; beatmapText.Clear(); - beatmapText.AddLink(item.Beatmap.ToString(), LinkAction.OpenBeatmap, item.Beatmap.Value.OnlineBeatmapID.ToString()); + beatmapText.AddLink(Item.Beatmap.ToString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString()); authorText.Clear(); - if (item.Beatmap?.Value?.Metadata?.Author != null) + if (Item.Beatmap?.Value?.Metadata?.Author != null) { authorText.AddText("mapped by "); - authorText.AddUserLink(item.Beatmap.Value?.Metadata.Author); + authorText.AddUserLink(Item.Beatmap.Value?.Metadata.Author); } modDisplay.Current.Value = requiredMods.ToArray(); @@ -180,29 +185,33 @@ namespace osu.Game.Screens.Multi } } }, - new Container + new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, X = -18, - Children = new Drawable[] - { - new PlaylistDownloadButton(item) - { - Size = new Vector2(50, 30) - }, - new IconButton - { - Icon = FontAwesome.Solid.MinusSquare, - Alpha = allowEdit ? 1 : 0, - Action = () => RequestDeletion?.Invoke(Model), - }, - } + ChildrenEnumerable = CreateButtons() } } }; + protected virtual IEnumerable CreateButtons() => + new Drawable[] + { + new PlaylistDownloadButton(Item) + { + Size = new Vector2(50, 30) + }, + new IconButton + { + Icon = FontAwesome.Solid.MinusSquare, + Alpha = allowEdit ? 1 : 0, + Action = () => RequestDeletion?.Invoke(Model), + }, + }; + protected override bool OnClick(ClickEvent e) { if (allowSelection) diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs new file mode 100644 index 0000000000..439aaaa275 --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylistWithResults : DrawableRoomPlaylist + { + public Action RequestShowResults; + + public DrawableRoomPlaylistWithResults() + : base(false, true) + { + } + + protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => + new DrawableRoomPlaylistItemWithResults(item, false, true) + { + RequestShowResults = () => RequestShowResults(item), + SelectedItem = { BindTarget = SelectedItem }, + }; + + private class DrawableRoomPlaylistItemWithResults : DrawableRoomPlaylistItem + { + public Action RequestShowResults; + + public DrawableRoomPlaylistItemWithResults(PlaylistItem item, bool allowEdit, bool allowSelection) + : base(item, allowEdit, allowSelection) + { + } + + protected override IEnumerable CreateButtons() => + base.CreateButtons().Prepend(new FilledIconButton + { + Icon = FontAwesome.Solid.ChartPie, + Action = () => RequestShowResults?.Invoke(), + TooltipText = "View results" + }); + + private class FilledIconButton : IconButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = colours.Gray4, + }); + } + } + } + } +} diff --git a/osu.Game/Screens/Multi/Header.cs b/osu.Game/Screens/Multi/Header.cs index 5b8e8a7fd9..653cb3791a 100644 --- a/osu.Game/Screens/Multi/Header.cs +++ b/osu.Game/Screens/Multi/Header.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -19,41 +20,41 @@ namespace osu.Game.Screens.Multi { public class Header : Container { - public const float HEIGHT = 121; - - private readonly HeaderBreadcrumbControl breadcrumbs; + public const float HEIGHT = 80; public Header(ScreenStack stack) { - MultiHeaderTitle title; RelativeSizeAxes = Axes.X; Height = HEIGHT; + HeaderBreadcrumbControl breadcrumbs; + MultiHeaderTitle title; + Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"2f2043"), + Colour = Color4Extensions.FromHex(@"#1f1921"), }, new Container { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, + Padding = new MarginPadding { Left = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, Children = new Drawable[] { title = new MultiHeaderTitle { Anchor = Anchor.CentreLeft, Origin = Anchor.BottomLeft, - X = -MultiHeaderTitle.ICON_WIDTH, }, breadcrumbs = new HeaderBreadcrumbControl(stack) { Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - }, + Origin = Anchor.BottomLeft + } }, }, }; @@ -67,32 +68,16 @@ namespace osu.Game.Screens.Multi breadcrumbs.Current.TriggerChange(); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + private class MultiHeaderTitle : CompositeDrawable { - breadcrumbs.StripColour = colours.Green; - } - - private class MultiHeaderTitle : CompositeDrawable, IHasAccentColour - { - public const float ICON_WIDTH = icon_size + spacing; - - private const float icon_size = 25; private const float spacing = 6; - private const int text_offset = 2; - private readonly SpriteIcon iconSprite; - private readonly OsuSpriteText title, pageText; + private readonly OsuSpriteText dot; + private readonly OsuSpriteText pageTitle; public IMultiplayerSubScreen Screen { - set => pageText.Text = value.ShortTitle.ToLowerInvariant(); - } - - public Color4 AccentColour - { - get => pageText.Colour; - set => pageText.Colour = value; + set => pageTitle.Text = value.ShortTitle.Titleize(); } public MultiHeaderTitle() @@ -108,32 +93,26 @@ namespace osu.Game.Screens.Multi Direction = FillDirection.Horizontal, Children = new Drawable[] { - iconSprite = new SpriteIcon + new OsuSpriteText { - Size = new Vector2(icon_size), - Anchor = Anchor.Centre, - Origin = Anchor.Centre + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "Multiplayer" }, - title = new OsuSpriteText + dot = new OsuSpriteText { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold), - Margin = new MarginPadding { Bottom = text_offset } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "·" }, - new Circle + pageTitle = new OsuSpriteText { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(4), - Colour = Color4.Gray, - }, - pageText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20), - Margin = new MarginPadding { Bottom = text_offset } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "Lounge" } } }, @@ -143,9 +122,7 @@ namespace osu.Game.Screens.Multi [BackgroundDependencyLoader] private void load(OsuColour colours) { - title.Text = "multi"; - iconSprite.Icon = OsuIcon.Multi; - AccentColour = colours.Yellow; + pageTitle.Colour = dot.Colour = colours.Yellow; } } @@ -154,12 +131,28 @@ namespace osu.Game.Screens.Multi public HeaderBreadcrumbControl(ScreenStack stack) : base(stack) { + RelativeSizeAxes = Axes.X; + StripColour = Color4.Transparent; } protected override void LoadComplete() { base.LoadComplete(); - AccentColour = Color4.White; + AccentColour = Color4Extensions.FromHex("#e35c99"); + } + + protected override TabItem CreateTabItem(IScreen value) => new HeaderBreadcrumbTabItem(value) + { + AccentColour = AccentColour + }; + + private class HeaderBreadcrumbTabItem : BreadcrumbTabItem + { + public HeaderBreadcrumbTabItem(IScreen value) + : base(value) + { + Bar.Colour = Color4.Transparent; + } } } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs index de02d779e1..8dd1b239e8 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs @@ -107,6 +107,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { + float stripWidth = side_strip_width * (Room.Category.Value == RoomCategory.Spotlight ? 2 : 1); + Children = new Drawable[] { new StatusColouredContainer(transition_duration) @@ -139,7 +141,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components new StatusColouredContainer(transition_duration) { RelativeSizeAxes = Axes.Y, - Width = side_strip_width, + Width = stripWidth, Child = new Box { RelativeSizeAxes = Axes.Both } }, new Container @@ -147,7 +149,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components RelativeSizeAxes = Axes.Y, Width = cover_width, Masking = true, - Margin = new MarginPadding { Left = side_strip_width }, + Margin = new MarginPadding { Left = stripWidth }, Child = new MultiplayerBackgroundSprite(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Both } }, new Container @@ -156,7 +158,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components Padding = new MarginPadding { Vertical = content_padding, - Left = side_strip_width + cover_width + content_padding, + Left = stripWidth + cover_width + content_padding, Right = content_padding, }, Children = new Drawable[] @@ -217,6 +219,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components Alpha = 0; } + protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected; + private class RoomName : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Multiplayer.Room.Name))] diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs index 2742ef3404..be1083ce8d 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs @@ -12,11 +12,11 @@ using osuTK.Graphics; namespace osu.Game.Screens.Multi.Lounge.Components { - public class FilterControl : SearchableListFilterControl + public class FilterControl : SearchableListFilterControl { protected override Color4 BackgroundColour => Color4.Black.Opacity(0.5f); - protected override PrimaryFilter DefaultTab => PrimaryFilter.Open; - protected override SecondaryFilter DefaultCategory => SecondaryFilter.Public; + protected override RoomStatusFilter DefaultTab => RoomStatusFilter.Open; + protected override RoomCategoryFilter DefaultCategory => RoomCategoryFilter.Any; protected override float ContentHorizontalPadding => base.ContentHorizontalPadding + OsuScreen.HORIZONTAL_OVERFLOW_PADDING; @@ -43,6 +43,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components ruleset.BindValueChanged(_ => updateFilter()); Search.Current.BindValueChanged(_ => scheduleUpdateFilter()); + Dropdown.Current.BindValueChanged(_ => updateFilter()); Tabs.Current.BindValueChanged(_ => updateFilter(), true); } @@ -61,26 +62,27 @@ namespace osu.Game.Screens.Multi.Lounge.Components filter.Value = new FilterCriteria { SearchString = Search.Current.Value ?? string.Empty, - PrimaryFilter = Tabs.Current.Value, - SecondaryFilter = DisplayStyleControl.Dropdown.Current.Value, + StatusFilter = Tabs.Current.Value, + RoomCategoryFilter = Dropdown.Current.Value, Ruleset = ruleset.Value }; } } - public enum PrimaryFilter + public enum RoomStatusFilter { Open, [Description("Recently Ended")] - RecentlyEnded, + Ended, Participated, Owned, } - public enum SecondaryFilter + public enum RoomCategoryFilter { - Public, - //Private, + Any, + Normal, + Spotlight } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs index 26d445e151..6d70225eec 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs @@ -8,8 +8,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components public class FilterCriteria { public string SearchString; - public PrimaryFilter PrimaryFilter; - public SecondaryFilter SecondaryFilter; + public RoomStatusFilter StatusFilter; + public RoomCategoryFilter RoomCategoryFilter; public RulesetInfo Ruleset; } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs index 02f2667802..e6f6ce5ed2 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs @@ -4,9 +4,8 @@ using System.Collections.Generic; 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.Graphics.Containers; using osu.Game.Screens.Multi.Components; using osuTK; @@ -15,7 +14,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components public class RoomInfo : MultiplayerComposite { private readonly List statusElements = new List(); - private readonly SpriteText roomName; + private readonly OsuTextFlowContainer roomName; public RoomInfo() { @@ -43,18 +42,23 @@ namespace osu.Game.Screens.Multi.Lounge.Components { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Children = new Drawable[] { - roomName = new OsuSpriteText { Font = OsuFont.GetFont(size: 30) }, + roomName = new OsuTextFlowContainer(t => t.Font = OsuFont.GetFont(size: 30)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, statusInfo = new RoomStatusInfo(), } }, typeInfo = new ModeTypeInfo { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight } } }, diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs index 891853dee5..77fbd606f4 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs @@ -24,6 +24,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { + OverlinedHeader participantsHeader; + InternalChildren = new Drawable[] { new Box @@ -55,22 +57,31 @@ namespace osu.Game.Screens.Multi.Lounge.Components RelativeSizeAxes = Axes.X, Margin = new MarginPadding { Vertical = 60 }, }, - new OverlinedParticipants(Direction.Horizontal) + participantsHeader = new OverlinedHeader("Recent Participants"), + new ParticipantsDisplay(Direction.Vertical) { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }, + Height = ParticipantsList.TILE_SIZE * 3, + Details = { BindTarget = participantsHeader.Details } + } } } }, + new Drawable[] { new OverlinedHeader("Playlist"), }, new Drawable[] { - new OverlinedPlaylist(false) { RelativeSizeAxes = Axes.Both }, + new DrawableRoomPlaylist(false, false) + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = Playlist } + }, }, }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), } } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs index f14aa5fd8c..447c99039a 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs @@ -9,13 +9,17 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Threading; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.Multi.Lounge.Components { - public class RoomsContainer : CompositeDrawable + public class RoomsContainer : CompositeDrawable, IKeyBindingHandler { public Action JoinRequested; @@ -73,14 +77,6 @@ namespace osu.Game.Screens.Multi.Lounge.Components if (!string.IsNullOrEmpty(criteria.SearchString)) matchingFilter &= r.FilterTerms.Any(term => term.IndexOf(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase) >= 0); - switch (criteria.SecondaryFilter) - { - default: - case SecondaryFilter.Public: - matchingFilter &= r.Room.Availability.Value == RoomAvailability.Public; - break; - } - r.MatchingFilter = matchingFilter; } }); @@ -88,8 +84,22 @@ namespace osu.Game.Screens.Multi.Lounge.Components private void addRooms(IEnumerable rooms) { - foreach (var r in rooms) - roomFlow.Add(new DrawableRoom(r) { Action = () => selectRoom(r) }); + foreach (var room in rooms) + { + roomFlow.Add(new DrawableRoom(room) + { + Action = () => + { + if (room == selectedRoom.Value) + { + joinSelected(); + return; + } + + selectRoom(room); + } + }); + } Filter(filter?.Value); } @@ -115,16 +125,100 @@ namespace osu.Game.Screens.Multi.Lounge.Components private void selectRoom(Room room) { - var drawable = roomFlow.FirstOrDefault(r => r.Room == room); - - if (drawable != null && drawable.State == SelectionState.Selected) - JoinRequested?.Invoke(room); - else - roomFlow.Children.ForEach(r => r.State = r.Room == room ? SelectionState.Selected : SelectionState.NotSelected); - + roomFlow.Children.ForEach(r => r.State = r.Room == room ? SelectionState.Selected : SelectionState.NotSelected); selectedRoom.Value = room; } + private void joinSelected() + { + if (selectedRoom.Value == null) return; + + JoinRequested?.Invoke(selectedRoom.Value); + } + + #region Key selection logic (shared with BeatmapCarousel) + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Select: + joinSelected(); + return true; + + case GlobalAction.SelectNext: + beginRepeatSelection(() => selectNext(1), action); + return true; + + case GlobalAction.SelectPrevious: + beginRepeatSelection(() => selectNext(-1), action); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + case GlobalAction.SelectPrevious: + endRepeatSelection(action); + break; + } + } + + private ScheduledDelegate repeatDelegate; + private object lastRepeatSource; + + /// + /// Begin repeating the specified selection action. + /// + /// The action to perform. + /// The source of the action. Used in conjunction with to only cancel the correct action (most recently pressed key). + private void beginRepeatSelection(Action action, object source) + { + endRepeatSelection(); + + lastRepeatSource = source; + repeatDelegate = this.BeginKeyRepeat(Scheduler, action); + } + + private void endRepeatSelection(object source = null) + { + // only the most recent source should be able to cancel the current action. + if (source != null && !EqualityComparer.Default.Equals(lastRepeatSource, source)) + return; + + repeatDelegate?.Cancel(); + repeatDelegate = null; + lastRepeatSource = null; + } + + private void selectNext(int direction) + { + var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); + + Room room; + + if (selectedRoom.Value == null) + room = visibleRooms.FirstOrDefault()?.Room; + else + { + if (direction < 0) + visibleRooms = visibleRooms.Reverse(); + + room = visibleRooms.SkipWhile(r => r.Room != selectedRoom.Value).Skip(1).FirstOrDefault()?.Room; + } + + // we already have a valid selection only change selection if we still have a room to switch to. + if (room != null) + selectRoom(room); + } + + #endregion + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs index d4b6a3b79f..ff7d56a95b 100644 --- a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,6 +11,7 @@ using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; using osu.Game.Overlays.SearchableList; using osu.Game.Screens.Multi.Lounge.Components; using osu.Game.Screens.Multi.Match; @@ -20,21 +22,26 @@ namespace osu.Game.Screens.Multi.Lounge { public override string Title => "Lounge"; - protected readonly FilterControl Filter; + protected FilterControl Filter; private readonly Bindable initialRoomsReceived = new Bindable(); - private readonly Container content; - private readonly LoadingLayer loadingLayer; + private Container content; + private LoadingLayer loadingLayer; [Resolved] private Bindable selectedRoom { get; set; } + [Resolved(canBeNull: true)] + private MusicController music { get; set; } + private bool joiningRoom; - public LoungeSubScreen() + [BackgroundDependencyLoader] + private void load() { - SearchContainer searchContainer; + RoomsContainer roomsContainer; + OsuScrollContainer scrollContainer; InternalChildren = new Drawable[] { @@ -50,19 +57,14 @@ namespace osu.Game.Screens.Multi.Lounge Width = 0.55f, Children = new Drawable[] { - new OsuScrollContainer + scrollContainer = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarOverlapsContent = false, Padding = new MarginPadding(10), - Child = searchContainer = new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new RoomsContainer { JoinRequested = joinRequested } - }, + Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested } }, - loadingLayer = new LoadingLayer(searchContainer), + loadingLayer = new LoadingLayer(roomsContainer), } }, new RoomInspector @@ -75,6 +77,14 @@ namespace osu.Game.Screens.Multi.Lounge }, }, }; + + // scroll selected room into view on selection. + selectedRoom.BindValueChanged(val => + { + var drawable = roomsContainer.Rooms.FirstOrDefault(r => r.Room == val.NewValue); + if (drawable != null) + scrollContainer.ScrollIntoView(drawable); + }); } protected override void LoadComplete() @@ -116,6 +126,8 @@ namespace osu.Game.Screens.Multi.Lounge if (selectedRoom.Value?.RoomID.Value == null) selectedRoom.Value = new Room(); + music.EnsurePlayingSomething(); + onReturning(); } diff --git a/osu.Game/Screens/Multi/Match/Components/Footer.cs b/osu.Game/Screens/Multi/Match/Components/Footer.cs index 94d7df6194..be4ee873fa 100644 --- a/osu.Game/Screens/Multi/Match/Components/Footer.cs +++ b/osu.Game/Screens/Multi/Match/Components/Footer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Multi.Match.Components { public class Footer : CompositeDrawable { - public const float HEIGHT = 100; + public const float HEIGHT = 50; public Action OnStart; public readonly Bindable SelectedItem = new Bindable(); diff --git a/osu.Game/Screens/Multi/Match/Components/Header.cs b/osu.Game/Screens/Multi/Match/Components/Header.cs index ddbaab1706..134a0b3f2e 100644 --- a/osu.Game/Screens/Multi/Match/Components/Header.cs +++ b/osu.Game/Screens/Multi/Match/Components/Header.cs @@ -52,7 +52,7 @@ namespace osu.Game.Screens.Multi.Match.Components Font = OsuFont.GetFont(size: 30), Current = { BindTarget = RoomName } }, - hostText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold)) + hostText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20)) { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, @@ -71,7 +71,7 @@ namespace osu.Game.Screens.Multi.Match.Components if (host.NewValue != null) { hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue); + hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); } }, true); } diff --git a/osu.Game/Screens/Multi/Match/Components/LeaderboardChatDisplay.cs b/osu.Game/Screens/Multi/Match/Components/LeaderboardChatDisplay.cs deleted file mode 100644 index de02b7f605..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/LeaderboardChatDisplay.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class LeaderboardChatDisplay : MultiplayerComposite - { - private const double fade_duration = 100; - - private readonly OsuTabControl tabControl; - private readonly MatchLeaderboard leaderboard; - private readonly MatchChatDisplay chat; - - public LeaderboardChatDisplay() - { - RelativeSizeAxes = Axes.Both; - - InternalChild = new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - tabControl = new DisplayModeTabControl - { - RelativeSizeAxes = Axes.X, - Height = 24, - } - }, - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 10 }, - Children = new Drawable[] - { - leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, - chat = new MatchChatDisplay - { - RelativeSizeAxes = Axes.Both, - Alpha = 0 - } - } - } - }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - tabControl.AccentColour = colours.Yellow; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - tabControl.Current.BindValueChanged(changeTab); - } - - public void RefreshScores() => leaderboard.RefreshScores(); - - private void changeTab(ValueChangedEvent mode) - { - chat.FadeTo(mode.NewValue == DisplayMode.Chat ? 1 : 0, fade_duration); - leaderboard.FadeTo(mode.NewValue == DisplayMode.Leaderboard ? 1 : 0, fade_duration); - } - - private class DisplayModeTabControl : OsuTabControl - { - protected override TabItem CreateTabItem(DisplayMode value) => base.CreateTabItem(value).With(d => - { - d.Anchor = Anchor.Centre; - d.Origin = Anchor.Centre; - }); - } - - private enum DisplayMode - { - Leaderboard, - Chat, - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs index 571bbde716..1afbf5c32a 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Online.Multiplayer; diff --git a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs index f837a407a5..7c2d5cf85d 100644 --- a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs +++ b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.GameTypes; @@ -52,10 +51,11 @@ namespace osu.Game.Screens.Multi.Match protected readonly Bindable SelectedItem = new Bindable(); - private LeaderboardChatDisplay leaderboardChatDisplay; private MatchSettingsOverlay settingsOverlay; + private MatchLeaderboard leaderboard; private IBindable> managerUpdated; + private OverlinedHeader participantsHeader; public MatchSubScreen(Room room) { @@ -85,87 +85,109 @@ namespace osu.Game.Screens.Multi.Match Child = new GridContainer { RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, Content = new[] { new Drawable[] { new Components.Header() }, new Drawable[] + { + participantsHeader = new OverlinedHeader("Participants") + { + ShowLine = false + } + }, + new Drawable[] { new Container { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 65 }, - Child = new GridContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 5 }, + Child = new ParticipantsDisplay(Direction.Horizontal) { - ColumnDimensions = new[] + Details = { BindTarget = participantsHeader.Details } + } + } + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { - new Dimension(minSize: 160), - new Dimension(minSize: 360), - new Dimension(minSize: 400), - }, - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] + new Container { - new Container + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = new GridContainer { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Child = new OverlinedParticipants(Direction.Vertical) { RelativeSizeAxes = Axes.Both } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5 }, - Child = new GridContainer + Content = new[] { - RelativeSizeAxes = Axes.Both, - Content = new[] + new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] { - new Drawable[] + new DrawableRoomPlaylistWithResults { - new OverlinedPlaylist(true) // Temporarily always allow selection + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = playlist }, + SelectedItem = { BindTarget = SelectedItem }, + RequestShowResults = item => { - RelativeSizeAxes = Axes.Both, - SelectedItem = { BindTarget = SelectedItem } - } - }, - null, - new Drawable[] - { - new TriangleButton - { - RelativeSizeAxes = Axes.X, - Text = "Show beatmap results", - Action = showBeatmapResults + Debug.Assert(roomId.Value != null); + multiplayer?.Push(new TimeshiftResultsScreen(null, roomId.Value.Value, item, false)); } } }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(GridSizeMode.AutoSize) - } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 5 }, - Child = leaderboardChatDisplay = new LeaderboardChatDisplay() } }, - } + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Leaderboard"), }, + new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + new Drawable[] { new OverlinedHeader("Chat"), }, + new Drawable[] { new MatchChatDisplay { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Relative, size: 0.4f, minSize: 240), + } + }, + null + }, + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), + new Dimension(), + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), + new Dimension(), } } } }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } } } }, @@ -261,16 +283,10 @@ namespace osu.Game.Screens.Multi.Match case GameTypeTimeshift _: multiplayer?.Push(new PlayerLoader(() => new TimeshiftPlayer(SelectedItem.Value) { - Exited = () => leaderboardChatDisplay.RefreshScores() + Exited = () => leaderboard.RefreshScores() })); break; } } - - private void showBeatmapResults() - { - Debug.Assert(roomId.Value != null); - multiplayer?.Push(new TimeshiftResultsScreen(null, roomId.Value.Value, SelectedItem.Value, false)); - } } } diff --git a/osu.Game/Screens/Multi/Multiplayer.cs b/osu.Game/Screens/Multi/Multiplayer.cs index e724152e08..4912df17b1 100644 --- a/osu.Game/Screens/Multi/Multiplayer.cs +++ b/osu.Game/Screens/Multi/Multiplayer.cs @@ -17,6 +17,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; using osu.Game.Screens.Menu; using osu.Game.Screens.Multi.Components; using osu.Game.Screens.Multi.Lounge; @@ -50,6 +51,9 @@ namespace osu.Game.Screens.Multi [Cached] private readonly Bindable currentFilter = new Bindable(new FilterCriteria()); + [Resolved] + private MusicController music { get; set; } + [Cached(Type = typeof(IRoomManager))] private RoomManager roomManager; @@ -117,7 +121,7 @@ namespace osu.Game.Screens.Multi Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(backgroundColour.Opacity(0.7f), backgroundColour) + Colour = ColourInfo.GradientVertical(backgroundColour.Opacity(0.5f), backgroundColour) }, } } @@ -250,12 +254,6 @@ namespace osu.Game.Screens.Multi { roomManager.PartRoom(); - if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) - { - screenStack.Exit(); - return true; - } - waves.Hide(); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); @@ -269,6 +267,20 @@ namespace osu.Game.Screens.Multi return false; } + public override bool OnBackButton() + { + if ((screenStack.CurrentScreen as IMultiplayerSubScreen)?.OnBackButton() == true) + return true; + + if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) + { + screenStack.Exit(); + return true; + } + + return false; + } + protected override void LogoExiting(OsuLogo logo) { base.LogoExiting(logo); @@ -338,8 +350,7 @@ namespace osu.Game.Screens.Multi track.RestartPoint = Beatmap.Value.Metadata.PreviewTime; track.Looping = true; - if (!track.IsRunning) - track.Restart(); + music.EnsurePlayingSomething(); } } else diff --git a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs index cf0197d26b..da082692d7 100644 --- a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs +++ b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -59,7 +58,7 @@ namespace osu.Game.Screens.Multi.Play if (!playlistItem.RequiredMods.All(m => Mods.Value.Any(m.Equals))) throw new InvalidOperationException("Current Mods do not match PlaylistItem's RequiredMods"); - var req = new CreateRoomScoreRequest(roomId.Value ?? 0, playlistItem.ID); + var req = new CreateRoomScoreRequest(roomId.Value ?? 0, playlistItem.ID, Game.VersionHash); req.Success += r => token = r.ID; req.Failure += e => { diff --git a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs index 5cafc974f1..8da6a530a8 100644 --- a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs +++ b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs @@ -3,13 +3,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -21,7 +22,15 @@ namespace osu.Game.Screens.Multi.Ranking private readonly int roomId; private readonly PlaylistItem playlistItem; - private LoadingSpinner loadingLayer; + protected LoadingSpinner LeftSpinner { get; private set; } + protected LoadingSpinner CentreSpinner { get; private set; } + protected LoadingSpinner RightSpinner { get; private set; } + + private MultiplayerScores higherScores; + private MultiplayerScores lowerScores; + + [Resolved] + private IAPIProvider api { get; set; } public TimeshiftResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) : base(score, allowRetry) @@ -33,29 +42,209 @@ namespace osu.Game.Screens.Multi.Ranking [BackgroundDependencyLoader] private void load() { - AddInternal(loadingLayer = new LoadingLayer + AddInternal(new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - X = -10, - State = { Value = Score == null ? Visibility.Visible : Visibility.Hidden }, - Padding = new MarginPadding { Bottom = TwoLayerButton.SIZE_EXTENDED.Y } + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = TwoLayerButton.SIZE_EXTENDED.Y }, + Children = new Drawable[] + { + LeftSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + }, + CentreSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { Value = Score == null ? Visibility.Visible : Visibility.Hidden }, + }, + RightSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + }, + } }); } protected override APIRequest FetchScores(Action> scoresCallback) { - var req = new GetRoomPlaylistScoresRequest(roomId, playlistItem.ID); + // This performs two requests: + // 1. A request to show the user's score (and scores around). + // 2. If that fails, a request to index the room starting from the highest score. - req.Success += r => + var userScoreReq = new ShowPlaylistUserScoreRequest(roomId, playlistItem.ID, api.LocalUser.Value.Id); + + userScoreReq.Success += userScore => { - scoresCallback?.Invoke(r.Scores.Where(s => s.ID != Score?.OnlineScoreID).Select(s => s.CreateScoreInfo(playlistItem))); - loadingLayer.Hide(); + var allScores = new List { userScore }; + + if (userScore.ScoresAround?.Higher != null) + { + allScores.AddRange(userScore.ScoresAround.Higher.Scores); + higherScores = userScore.ScoresAround.Higher; + + Debug.Assert(userScore.Position != null); + setPositions(higherScores, userScore.Position.Value, -1); + } + + if (userScore.ScoresAround?.Lower != null) + { + allScores.AddRange(userScore.ScoresAround.Lower.Scores); + lowerScores = userScore.ScoresAround.Lower; + + Debug.Assert(userScore.Position != null); + setPositions(lowerScores, userScore.Position.Value, 1); + } + + performSuccessCallback(scoresCallback, allScores); }; - req.Failure += _ => loadingLayer.Hide(); + // On failure, fallback to a normal index. + userScoreReq.Failure += _ => api.Queue(createIndexRequest(scoresCallback)); - return req; + return userScoreReq; + } + + protected override APIRequest FetchNextPage(int direction, Action> scoresCallback) + { + Debug.Assert(direction == 1 || direction == -1); + + MultiplayerScores pivot = direction == -1 ? higherScores : lowerScores; + + if (pivot?.Cursor == null) + return null; + + if (pivot == higherScores) + LeftSpinner.Show(); + else + RightSpinner.Show(); + + return createIndexRequest(scoresCallback, pivot); + } + + /// + /// Creates a with an optional score pivot. + /// + /// Does not queue the request. + /// The callback to perform with the resulting scores. + /// An optional score pivot to retrieve scores around. Can be null to retrieve scores from the highest score. + /// The indexing . + private APIRequest createIndexRequest(Action> scoresCallback, [CanBeNull] MultiplayerScores pivot = null) + { + var indexReq = pivot != null + ? new IndexPlaylistScoresRequest(roomId, playlistItem.ID, pivot.Cursor, pivot.Params) + : new IndexPlaylistScoresRequest(roomId, playlistItem.ID); + + indexReq.Success += r => + { + if (pivot == lowerScores) + { + lowerScores = r; + setPositions(r, pivot, 1); + } + else + { + higherScores = r; + setPositions(r, pivot, -1); + } + + performSuccessCallback(scoresCallback, r.Scores, r); + }; + + indexReq.Failure += _ => hideLoadingSpinners(pivot); + + return indexReq; + } + + /// + /// Transforms returned into s, ensure the is put into a sane state, and invokes a given success callback. + /// + /// The callback to invoke with the final s. + /// The s that were retrieved from s. + /// An optional pivot around which the scores were retrieved. + private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) + { + var scoreInfos = new List(scores.Select(s => s.CreateScoreInfo(playlistItem))); + + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) + { + Schedule(() => + { + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } + + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); + + hideLoadingSpinners(pivot); + } + + private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) + { + CentreSpinner.Hide(); + + if (pivot == lowerScores) + RightSpinner.Hide(); + else if (pivot == higherScores) + LeftSpinner.Hide(); + } + + /// + /// Applies positions to all s referenced to a given pivot. + /// + /// The to set positions on. + /// The pivot. + /// The amount to increment the pivot position by for each in . + private void setPositions([NotNull] MultiplayerScores scores, [CanBeNull] MultiplayerScores pivot, int increment) + => setPositions(scores, pivot?.Scores[^1].Position ?? 0, increment); + + /// + /// Applies positions to all s referenced to a given pivot. + /// + /// The to set positions on. + /// The pivot position. + /// The amount to increment the pivot position by for each in . + private void setPositions([NotNull] MultiplayerScores scores, int pivotPosition, int increment) + { + foreach (var s in scores.Scores) + { + pivotPosition += increment; + s.Position = pivotPosition; + } + } + + private class PanelListLoadingSpinner : LoadingSpinner + { + private readonly ScorePanelList list; + + /// + /// Creates a new . + /// + /// The list to track. + /// Whether the spinner should have a surrounding black box for visibility. + public PanelListLoadingSpinner(ScorePanelList list, bool withBox = true) + : base(withBox) + { + this.list = list; + } + + protected override void Update() + { + base.Update(); + + float panelOffset = list.DrawWidth / 2 - ScorePanel.EXPANDED_WIDTH; + + if ((Anchor & Anchor.x0) > 0) + X = (float)(panelOffset - list.Current); + else if ((Anchor & Anchor.x2) > 0) + X = (float)(list.ScrollableExtent - list.Current - panelOffset); + } } } } diff --git a/osu.Game/Screens/Multi/RoomManager.cs b/osu.Game/Screens/Multi/RoomManager.cs index 4d6ac46c84..2a96fa536d 100644 --- a/osu.Game/Screens/Multi/RoomManager.cs +++ b/osu.Game/Screens/Multi/RoomManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -13,7 +14,6 @@ using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; using osu.Game.Screens.Multi.Lounge.Components; @@ -113,7 +113,7 @@ namespace osu.Game.Screens.Multi public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) { currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room, api.LocalUser.Value); + currentJoinRoomRequest = new JoinRoomRequest(room); currentJoinRoomRequest.Success += () => { @@ -138,10 +138,12 @@ namespace osu.Game.Screens.Multi if (joinedRoom == null) return; - api.Queue(new PartRoomRequest(joinedRoom, api.LocalUser.Value)); + api.Queue(new PartRoomRequest(joinedRoom)); joinedRoom = null; } + private readonly HashSet ignoredRooms = new HashSet(); + /// /// Invoked when the listing of all s is received from the server. /// @@ -163,11 +165,27 @@ namespace osu.Game.Screens.Multi continue; } - var r = listing[i]; - r.Position.Value = i; + var room = listing[i]; - update(r, r); - addRoom(r); + Debug.Assert(room.RoomID.Value != null); + + if (ignoredRooms.Contains(room.RoomID.Value.Value)) + continue; + + room.Position.Value = i; + + try + { + update(room, room); + addRoom(room); + } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); + + ignoredRooms.Add(room.RoomID.Value.Value); + rooms.Remove(room); + } } RoomsUpdated?.Invoke(); @@ -299,7 +317,7 @@ namespace osu.Game.Screens.Multi var tcs = new TaskCompletionSource(); pollReq?.Cancel(); - pollReq = new GetRoomsRequest(currentFilter.Value.PrimaryFilter); + pollReq = new GetRoomsRequest(currentFilter.Value.StatusFilter, currentFilter.Value.RoomCategoryFilter); pollReq.Success += result => { diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 35bb4fa34f..872a1cd39a 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -258,5 +258,7 @@ namespace osu.Game.Screens /// Note that the instance created may not be the used instance if it matches the BackgroundMode equality clause. /// protected virtual BackgroundScreen CreateBackground() => null; + + public virtual bool OnBackButton() => false; } } diff --git a/osu.Game/Screens/Play/ComboEffects.cs b/osu.Game/Screens/Play/ComboEffects.cs index 1c4ac921f0..5bcda50399 100644 --- a/osu.Game/Screens/Play/ComboEffects.cs +++ b/osu.Game/Screens/Play/ComboEffects.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; @@ -16,27 +17,34 @@ namespace osu.Game.Screens.Play private SkinnableSound comboBreakSample; + private Bindable alwaysPlay; + private bool firstTime = true; + public ComboEffects(ScoreProcessor processor) { this.processor = processor; } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { InternalChild = comboBreakSample = new SkinnableSound(new SampleInfo("combobreak")); + alwaysPlay = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak); } protected override void LoadComplete() { base.LoadComplete(); - processor.Combo.BindValueChanged(onComboChange, true); + processor.Combo.BindValueChanged(onComboChange); } private void onComboChange(ValueChangedEvent combo) { - if (combo.NewValue == 0 && combo.OldValue > 20) + if (combo.NewValue == 0 && (combo.OldValue > 20 || (alwaysPlay.Value && firstTime))) + { comboBreakSample?.Play(); + firstTime = false; + } } } } diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 6b37135c86..57403a0987 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -24,7 +24,8 @@ namespace osu.Game.Screens.Play { public abstract class GameplayMenuOverlay : OverlayContainer, IKeyBindingHandler { - private const int transition_duration = 200; + protected const int TRANSITION_DURATION = 200; + private const int button_height = 70; private const float background_alpha = 0.75f; @@ -156,8 +157,8 @@ namespace osu.Game.Screens.Play } } - protected override void PopIn() => this.FadeIn(transition_duration, Easing.In); - protected override void PopOut() => this.FadeOut(transition_duration, Easing.In); + protected override void PopIn() => this.FadeIn(TRANSITION_DURATION, Easing.In); + protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); // Don't let mouse down events through the overlay or people can click circles while paused. protected override bool OnMouseDown(MouseDownEvent e) => true; diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs index a49aa89a7c..847b8a53cf 100644 --- a/osu.Game/Screens/Play/HUD/FailingLayer.cs +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -18,23 +18,27 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { /// - /// An overlay layer on top of the playfield which fades to red when the current player health falls below a certain threshold defined by . + /// An overlay layer on top of the playfield which fades to red when the current player health falls below a certain threshold defined by . /// public class FailingLayer : HealthDisplay { + /// + /// Whether the current player health should be shown on screen. + /// + public readonly Bindable ShowHealth = new Bindable(); + private const float max_alpha = 0.4f; private const int fade_time = 400; - private const float gradient_size = 0.3f; + private const float gradient_size = 0.2f; /// /// The threshold under which the current player life should be considered low and the layer should start fading in. /// - public double LowHealthThreshold = 0.20f; + private const double low_health_threshold = 0.20f; - private readonly Bindable enabled = new Bindable(); private readonly Container boxes; - private Bindable configEnabled; + private Bindable fadePlayfieldWhenHealthLow; private HealthProcessor healthProcessor; public FailingLayer() @@ -52,16 +56,16 @@ namespace osu.Game.Screens.Play.HUD new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0)), - Height = gradient_size, + Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.White.Opacity(0)), + Width = gradient_size, }, new Box { RelativeSizeAxes = Axes.Both, - Height = gradient_size, - Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0), Color4.White), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Width = gradient_size, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, }, } }, @@ -73,14 +77,15 @@ namespace osu.Game.Screens.Play.HUD { boxes.Colour = color.Red; - configEnabled = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow); - enabled.BindValueChanged(e => this.FadeTo(e.NewValue ? 1 : 0, fade_time, Easing.OutQuint), true); + fadePlayfieldWhenHealthLow = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow); + fadePlayfieldWhenHealthLow.BindValueChanged(_ => updateState()); + ShowHealth.BindValueChanged(_ => updateState()); } protected override void LoadComplete() { base.LoadComplete(); - updateBindings(); + updateState(); } public override void BindHealthProcessor(HealthProcessor processor) @@ -88,26 +93,19 @@ namespace osu.Game.Screens.Play.HUD base.BindHealthProcessor(processor); healthProcessor = processor; - updateBindings(); + updateState(); } - private void updateBindings() + private void updateState() { - if (LoadState < LoadState.Ready) - return; - - enabled.UnbindBindings(); - // Don't display ever if the ruleset is not using a draining health display. - if (healthProcessor is DrainingHealthProcessor) - enabled.BindTo(configEnabled); - else - enabled.Value = false; + var showLayer = healthProcessor is DrainingHealthProcessor && fadePlayfieldWhenHealthLow.Value && ShowHealth.Value; + this.FadeTo(showLayer ? 1 : 0, fade_time, Easing.OutQuint); } protected override void Update() { - double target = Math.Clamp(max_alpha * (1 - Current.Value / LowHealthThreshold), 0, max_alpha); + double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha); boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f); diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 684834123b..387c0e587b 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -251,6 +251,7 @@ namespace osu.Game.Screens.Play.HUD switch (action) { case GlobalAction.Back: + case GlobalAction.PauseGameplay: // in the future this behaviour will differ for replays etc. if (!pendingAnimation) BeginConfirm(); return true; @@ -264,6 +265,7 @@ namespace osu.Game.Screens.Play.HUD switch (action) { case GlobalAction.Back: + case GlobalAction.PauseGameplay: AbortConfirm(); break; } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 5114efd9a9..f09745cf71 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -262,7 +262,10 @@ namespace osu.Game.Screens.Play Margin = new MarginPadding { Top = 20 } }; - protected virtual FailingLayer CreateFailingLayer() => new FailingLayer(); + protected virtual FailingLayer CreateFailingLayer() => new FailingLayer + { + ShowHealth = { BindTarget = ShowHealthbar } + }; protected virtual KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay { diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 6cc6027a03..fa917cda32 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -4,7 +4,10 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Screens.Play @@ -13,17 +16,46 @@ namespace osu.Game.Screens.Play { public Action OnResume; + public override bool IsPresent => base.IsPresent || pauseLoop.IsPlaying; + public override string Header => "paused"; public override string Description => "you're not going to do what i think you're going to do, are ya?"; + private SkinnableSound pauseLoop; + protected override Action BackAction => () => InternalButtons.Children.First().Click(); + private const float minimum_volume = 0.0001f; + [BackgroundDependencyLoader] private void load(OsuColour colours) { AddButton("Continue", colours.Green, () => OnResume?.Invoke()); AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + + AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("pause-loop")) + { + Looping = true, + }); + + // SkinnableSound only plays a sound if its aggregate volume is > 0, so the volume must be turned up before playing it + pauseLoop.VolumeTo(minimum_volume); + } + + protected override void PopIn() + { + base.PopIn(); + + pauseLoop.VolumeTo(1.0f, TRANSITION_DURATION, Easing.InQuint); + pauseLoop.Play(); + } + + protected override void PopOut() + { + base.PopOut(); + + pauseLoop.VolumeTo(minimum_volume, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); } } } diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index b443603128..7f5c17a265 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Screens; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -25,13 +24,16 @@ namespace osu.Game.Screens.Play DrawableRuleset?.SetReplayScore(score); } - protected override void GotoRanking() - { - this.Push(CreateResults(DrawableRuleset.ReplayScore.ScoreInfo)); - } - protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); - protected override ScoreInfo CreateScore() => score.ScoreInfo; + protected override ScoreInfo CreateScore() + { + var baseScore = base.CreateScore(); + + // Since the replay score doesn't contain statistics, we'll pass them through here. + score.ScoreInfo.HitEvents = baseScore.HitEvents; + + return score.ScoreInfo; + } } } diff --git a/osu.Game/Screens/Play/SongProgressBar.cs b/osu.Game/Screens/Play/SongProgressBar.cs index 5052b32335..939b5fad1f 100644 --- a/osu.Game/Screens/Play/SongProgressBar.cs +++ b/osu.Game/Screens/Play/SongProgressBar.cs @@ -57,6 +57,8 @@ namespace osu.Game.Screens.Play set => CurrentNumber.Value = value; } + protected override bool AllowKeyboardInputWhenNotHovered => true; + public SongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize) { CurrentNumber.MinValue = 0; diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs new file mode 100644 index 0000000000..0935ee7fb2 --- /dev/null +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Ranking.Contracted +{ + public class ContractedPanelTopContent : CompositeDrawable + { + private readonly ScoreInfo score; + + public ContractedPanelTopContent(ScoreInfo score) + { + this.score = score; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = 6, + Text = score.Position != null ? $"#{score.Position}" : string.Empty, + Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index ee53ee9879..45da23f1f9 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Graphics; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osuTK; @@ -191,18 +193,26 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Padding = new MarginPadding { Vertical = -15, Horizontal = -20 }, Children = new[] { - new RankBadge(1f, ScoreRank.X), - new RankBadge(0.95f, ScoreRank.S), - new RankBadge(0.9f, ScoreRank.A), - new RankBadge(0.8f, ScoreRank.B), - new RankBadge(0.7f, ScoreRank.C), - new RankBadge(0.35f, ScoreRank.D), + new RankBadge(1f, getRank(ScoreRank.X)), + new RankBadge(0.95f, getRank(ScoreRank.S)), + new RankBadge(0.9f, getRank(ScoreRank.A)), + new RankBadge(0.8f, getRank(ScoreRank.B)), + new RankBadge(0.7f, getRank(ScoreRank.C)), + new RankBadge(0.35f, getRank(ScoreRank.D)), } }, rankText = new RankText(score.Rank) }; } + private ScoreRank getRank(ScoreRank rank) + { + foreach (var mod in score.Mods.OfType()) + rank = mod.AdjustRank(rank, score.Accuracy); + + return rank; + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs index 8343716e7e..cc732382f4 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs @@ -77,11 +77,10 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Origin = Anchor.Centre, BlurSigma = new Vector2(35), BypassAutoSizeAxes = Axes.Both, - RelativeSizeAxes = Axes.Both, + Size = new Vector2(200), CacheDrawnFrameBuffer = true, Blending = BlendingParameters.Additive, Alpha = 0, - Size = new Vector2(2f), // increase buffer size to allow for scale Scale = new Vector2(1.8f), Children = new[] { @@ -122,15 +121,18 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy } flash.Colour = OsuColour.ForRank(rank); - flash.FadeIn().Then().FadeOut(1200, Easing.OutQuint); if (rank >= ScoreRank.S) rankText.ScaleTo(1.05f).ScaleTo(1, 3000, Easing.OutQuint); if (rank >= ScoreRank.X) { - flash.FadeIn().Then().FadeOut(3000); - superFlash.FadeIn().Then().FadeOut(800, Easing.OutQuint); + flash.FadeOutFromOne(3000); + superFlash.FadeOutFromOne(800, Easing.OutQuint); + } + else + { + flash.FadeOutFromOne(1200, Easing.OutQuint); } } } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index fbb9b95478..c95cf1066e 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -16,6 +17,7 @@ using osu.Game.Online.API; using osu.Game.Scoring; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking.Statistics; using osuTK; namespace osu.Game.Screens.Ranking @@ -23,6 +25,7 @@ namespace osu.Game.Screens.Ranking public abstract class ResultsScreen : OsuScreen { protected const float BACKGROUND_BLUR = 20; + private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -34,7 +37,8 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); public readonly ScoreInfo Score; - private readonly bool allowRetry; + + protected ScorePanelList ScorePanelList { get; private set; } [Resolved(CanBeNull = true)] private Player player { get; set; } @@ -42,8 +46,14 @@ namespace osu.Game.Screens.Ranking [Resolved] private IAPIProvider api { get; set; } + private StatisticsPanel statisticsPanel; private Drawable bottomPanel; - private ScorePanelList panels; + private Container detachedPanelContainer; + + private bool fetchedInitialScores; + private APIRequest nextPageRequest; + + private readonly bool allowRetry; protected ResultsScreen(ScoreInfo score, bool allowRetry = true) { @@ -65,14 +75,33 @@ namespace osu.Game.Screens.Ranking { new Drawable[] { - new ResultsScrollContainer + new VerticalScrollContainer { - Child = panels = new ScorePanelList + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new Container { RelativeSizeAxes = Axes.Both, - SelectedScore = { BindTarget = SelectedScore } + Children = new Drawable[] + { + statisticsPanel = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + Score = { BindTarget = SelectedScore } + }, + ScorePanelList = new ScorePanelList + { + RelativeSizeAxes = Axes.Both, + SelectedScore = { BindTarget = SelectedScore }, + PostExpandAction = () => statisticsPanel.ToggleVisibility() + }, + detachedPanelContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + } } - } + }, }, new[] { @@ -118,7 +147,7 @@ namespace osu.Game.Screens.Ranking }; if (Score != null) - panels.AddScore(Score); + ScorePanelList.AddScore(Score); if (player != null && allowRetry) { @@ -140,14 +169,34 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); - var req = FetchScores(scores => Schedule(() => - { - foreach (var s in scores) - panels.AddScore(s); - })); + var req = FetchScores(fetchScoresCallback); if (req != null) api.Queue(req); + + statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + } + + protected override void Update() + { + base.Update(); + + if (fetchedInitialScores && nextPageRequest == null) + { + if (ScorePanelList.IsScrolledToStart) + nextPageRequest = FetchNextPage(-1, fetchScoresCallback); + else if (ScorePanelList.IsScrolledToEnd) + nextPageRequest = FetchNextPage(1, fetchScoresCallback); + + if (nextPageRequest != null) + { + // Scheduled after children to give the list a chance to update its scroll position and not potentially trigger a second request too early. + nextPageRequest.Success += () => ScheduleAfterChildren(() => nextPageRequest = null); + nextPageRequest.Failure += _ => ScheduleAfterChildren(() => nextPageRequest = null); + + api.Queue(nextPageRequest); + } + } } /// @@ -157,6 +206,22 @@ namespace osu.Game.Screens.Ranking /// An responsible for the fetch operation. This will be queued and performed automatically. protected virtual APIRequest FetchScores(Action> scoresCallback) => null; + /// + /// Performs a fetch of the next page of scores. This is invoked every frame until a non-null is returned. + /// + /// The fetch direction. -1 to fetch scores greater than the current start of the list, and 1 to fetch scores lower than the current end of the list. + /// A callback which should be called when fetching is completed. Scheduling is not required. + /// An responsible for the fetch operation. This will be queued and performed automatically. + protected virtual APIRequest FetchNextPage(int direction, Action> scoresCallback) => null; + + private void fetchScoresCallback(IEnumerable scores) => Schedule(() => + { + foreach (var s in scores) + addScore(s); + + fetchedInitialScores = true; + }); + public override void OnEntering(IScreen last) { base.OnEntering(last); @@ -174,27 +239,96 @@ namespace osu.Game.Screens.Ranking return base.OnExiting(next); } - private class ResultsScrollContainer : OsuScrollContainer + public override bool OnBackButton() { - private readonly Container content; + if (statisticsPanel.State.Value == Visibility.Visible) + { + statisticsPanel.Hide(); + return true; + } + return false; + } + + private void addScore(ScoreInfo score) + { + var panel = ScorePanelList.AddScore(score); + + if (detachedPanel != null) + panel.Alpha = 0; + } + + private ScorePanel detachedPanel; + + private void onStatisticsStateChanged(ValueChangedEvent state) + { + if (state.NewValue == Visibility.Visible) + { + // Detach the panel in its original location, and move into the desired location in the local container. + var expandedPanel = ScorePanelList.GetPanelForScore(SelectedScore.Value); + var screenSpacePos = expandedPanel.ScreenSpaceDrawQuad.TopLeft; + + // Detach and move into the local container. + ScorePanelList.Detach(expandedPanel); + detachedPanelContainer.Add(expandedPanel); + + // Move into its original location in the local container first, then to the final location. + var origLocation = detachedPanelContainer.ToLocalSpace(screenSpacePos); + expandedPanel.MoveTo(origLocation) + .Then() + .MoveTo(new Vector2(StatisticsPanel.SIDE_PADDING, origLocation.Y), 150, Easing.OutQuint); + + // Hide contracted panels. + foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + contracted.FadeOut(150, Easing.OutQuint); + ScorePanelList.HandleInput = false; + + // Dim background. + Background.FadeTo(0.1f, 150); + + detachedPanel = expandedPanel; + } + else if (detachedPanel != null) + { + var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft; + + // Remove from the local container and re-attach. + detachedPanelContainer.Remove(detachedPanel); + ScorePanelList.Attach(detachedPanel); + + // Move into its original location in the attached container first, then to the final location. + var origLocation = detachedPanel.Parent.ToLocalSpace(screenSpacePos); + detachedPanel.MoveTo(origLocation) + .Then() + .MoveTo(new Vector2(0, origLocation.Y), 150, Easing.OutQuint); + + // Show contracted panels. + foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + contracted.FadeIn(150, Easing.OutQuint); + ScorePanelList.HandleInput = true; + + // Un-dim background. + Background.FadeTo(0.5f, 150); + + detachedPanel = null; + } + } + + private class VerticalScrollContainer : OsuScrollContainer + { protected override Container Content => content; - public ResultsScrollContainer() - { - base.Content.Add(content = new Container - { - RelativeSizeAxes = Axes.X - }); + private readonly Container content; - RelativeSizeAxes = Axes.Both; - ScrollbarVisible = false; + public VerticalScrollContainer() + { + base.Content.Add(content = new Container { RelativeSizeAxes = Axes.X }); } protected override void Update() { base.Update(); - content.Height = Math.Max(768 - TwoLayerButton.SIZE_EXTENDED.Y, DrawHeight); + content.Height = Math.Max(screen_height, DrawHeight); } } } diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index 65fb901c89..1904da7094 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -13,6 +13,7 @@ using osu.Framework.Input.Events; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Contracted; using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Users; using osuTK; using osuTK.Graphics; @@ -76,6 +77,12 @@ namespace osu.Game.Screens.Ranking private static readonly Color4 contracted_middle_layer_colour = Color4Extensions.FromHex("#353535"); public event Action StateChanged; + + /// + /// An action to be invoked if this is clicked while in an expanded state. + /// + public Action PostExpandAction; + public readonly ScoreInfo Score; private Container content; @@ -136,7 +143,16 @@ namespace osu.Game.Screens.Ranking CornerRadius = 20, CornerExponent = 2.5f, Masking = true, - Child = middleLayerBackground = new Box { RelativeSizeAxes = Axes.Both } + Children = new[] + { + middleLayerBackground = new Box { RelativeSizeAxes = Axes.Both }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = Score.User, + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.5f), Color4Extensions.FromHex("#444").Opacity(0)) + } + } }, middleLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } } @@ -149,18 +165,10 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); - if (state == PanelState.Expanded) - { - topLayerBackground.FadeColour(expanded_top_layer_colour); - middleLayerBackground.FadeColour(expanded_middle_layer_colour); - } - else - { - topLayerBackground.FadeColour(contracted_top_layer_colour); - middleLayerBackground.FadeColour(contracted_middle_layer_colour); - } - updateState(); + + topLayerBackground.FinishTransforms(false, nameof(Colour)); + middleLayerBackground.FinishTransforms(false, nameof(Colour)); } private PanelState state = PanelState.Contracted; @@ -195,8 +203,8 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(expanded_top_layer_colour, resize_duration, Easing.OutQuint); middleLayerBackground.FadeColour(expanded_middle_layer_colour, resize_duration, Easing.OutQuint); - topLayerContentContainer.Add(middleLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); - middleLayerContentContainer.Add(topLayerContent = new ExpandedPanelMiddleContent(Score).With(d => d.Alpha = 0)); + topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); + middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score).With(d => d.Alpha = 0)); break; case PanelState.Contracted: @@ -205,7 +213,8 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(contracted_top_layer_colour, resize_duration, Easing.OutQuint); middleLayerBackground.FadeColour(contracted_middle_layer_colour, resize_duration, Easing.OutQuint); - middleLayerContentContainer.Add(topLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); + topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent(Score).With(d => d.Alpha = 0)); + middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); break; } @@ -236,10 +245,28 @@ namespace osu.Game.Screens.Ranking } } + public override Vector2 Size + { + get => base.Size; + set + { + base.Size = value; + + // Auto-size isn't used to avoid 1-frame issues and because the score panel is removed/re-added to the container. + if (trackingContainer != null) + trackingContainer.Size = value; + } + } + protected override bool OnClick(ClickEvent e) { if (State == PanelState.Contracted) + { State = PanelState.Expanded; + return true; + } + + PostExpandAction?.Invoke(); return true; } @@ -248,5 +275,24 @@ namespace osu.Game.Screens.Ranking => base.ReceivePositionalInputAt(screenSpacePos) || topLayerContainer.ReceivePositionalInputAt(screenSpacePos) || middleLayerContainer.ReceivePositionalInputAt(screenSpacePos); + + private ScorePanelTrackingContainer trackingContainer; + + /// + /// Creates a which this can reside inside. + /// The will track the size of this . + /// + /// + /// This is immediately added as a child of the . + /// + /// The . + /// If a already exists. + public ScorePanelTrackingContainer CreateTrackingContainer() + { + if (trackingContainer != null) + throw new InvalidOperationException("A score panel container has already been created."); + + return trackingContainer = new ScorePanelTrackingContainer(this); + } } } diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 1142297274..10bd99c8ce 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -25,12 +26,45 @@ namespace osu.Game.Screens.Ranking /// private const float expanded_panel_spacing = 15; + /// + /// Minimum distance from either end point of the list that the list can be considered scrolled to the end point. + /// + private const float scroll_endpoint_distance = 100; + + /// + /// Whether this can be scrolled and is currently scrolled to the start. + /// + public bool IsScrolledToStart => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance; + + /// + /// Whether this can be scrolled and is currently scrolled to the end. + /// + public bool IsScrolledToEnd => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance); + + /// + /// The current scroll position. + /// + public double Current => scroll.Current; + + /// + /// The scrollable extent. + /// + public double ScrollableExtent => scroll.ScrollableExtent; + + /// + /// An action to be invoked if a is clicked while in an expanded state. + /// + public Action PostExpandAction; + public readonly Bindable SelectedScore = new Bindable(); private readonly Flow flow; private readonly Scroll scroll; private ScorePanel expandedPanel; + /// + /// Creates a new . + /// public ScorePanelList() { RelativeSizeAxes = Axes.Both; @@ -38,6 +72,7 @@ namespace osu.Game.Screens.Ranking InternalChild = scroll = new Scroll { RelativeSizeAxes = Axes.Both, + HandleScroll = () => expandedPanel?.IsHovered != true, // handle horizontal scroll only when not hovering the expanded panel. Child = flow = new Flow { Anchor = Anchor.Centre, @@ -60,12 +95,11 @@ namespace osu.Game.Screens.Ranking /// Adds a to this list. /// /// The to add. - public void AddScore(ScoreInfo score) + public ScorePanel AddScore(ScoreInfo score) { - flow.Add(new ScorePanel(score) + var panel = new ScorePanel(score) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + PostExpandAction = () => PostExpandAction?.Invoke() }.With(p => { p.StateChanged += s => @@ -73,6 +107,12 @@ namespace osu.Game.Screens.Ranking if (s == PanelState.Expanded) SelectedScore.Value = p.Score; }; + }); + + flow.Add(panel.CreateTrackingContainer().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; })); if (SelectedScore.Value == score) @@ -90,6 +130,8 @@ namespace osu.Game.Screens.Ranking scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; } } + + return panel; } /// @@ -99,24 +141,24 @@ namespace osu.Game.Screens.Ranking private void selectedScoreChanged(ValueChangedEvent score) { // Contract the old panel. - foreach (var p in flow.Where(p => p.Score == score.OldValue)) + foreach (var t in flow.Where(t => t.Panel.Score == score.OldValue)) { - p.State = PanelState.Contracted; - p.Margin = new MarginPadding(); + t.Panel.State = PanelState.Contracted; + t.Margin = new MarginPadding(); } // Find the panel corresponding to the new score. - expandedPanel = flow.SingleOrDefault(p => p.Score == score.NewValue); - - // handle horizontal scroll only when not hovering the expanded panel. - scroll.HandleScroll = () => expandedPanel?.IsHovered != true; + var expandedTrackingComponent = flow.SingleOrDefault(t => t.Panel.Score == score.NewValue); + expandedPanel = expandedTrackingComponent?.Panel; if (expandedPanel == null) return; + Debug.Assert(expandedTrackingComponent != null); + // Expand the new panel. + expandedTrackingComponent.Margin = new MarginPadding { Horizontal = expanded_panel_spacing }; expandedPanel.State = PanelState.Expanded; - expandedPanel.Margin = new MarginPadding { Horizontal = expanded_panel_spacing }; // Scroll to the new panel. This is done manually since we need: // 1) To scroll after the scroll container's visible range is updated. @@ -145,15 +187,92 @@ namespace osu.Game.Screens.Ranking flow.Padding = new MarginPadding { Horizontal = offset }; } - private class Flow : FillFlowContainer + private bool handleInput = true; + + /// + /// Whether this or any of the s contained should handle scroll or click input. + /// Setting to false will also hide the scrollbar. + /// + public bool HandleInput + { + get => handleInput; + set + { + handleInput = value; + scroll.ScrollbarVisible = value; + } + } + + public override bool PropagatePositionalInputSubTree => HandleInput && base.PropagatePositionalInputSubTree; + + public override bool PropagateNonPositionalInputSubTree => HandleInput && base.PropagateNonPositionalInputSubTree; + + /// + /// Enumerates all s contained in this . + /// + /// + public IEnumerable GetScorePanels() => flow.Select(t => t.Panel); + + /// + /// Finds the corresponding to a . + /// + /// The to find the corresponding for. + /// The . + public ScorePanel GetPanelForScore(ScoreInfo score) => flow.Single(t => t.Panel.Score == score).Panel; + + /// + /// Detaches a from its , allowing the panel to be moved elsewhere in the hierarchy. + /// + /// The to detach. + /// If is not a part of this . + public void Detach(ScorePanel panel) + { + var container = flow.SingleOrDefault(t => t.Panel == panel); + if (container == null) + throw new InvalidOperationException("Panel is not contained by the score panel list."); + + container.Detach(); + } + + /// + /// Attaches a to its in this . + /// + /// The to attach. + /// If is not a part of this . + public void Attach(ScorePanel panel) + { + var container = flow.SingleOrDefault(t => t.Panel == panel); + if (container == null) + throw new InvalidOperationException("Panel is not contained by the score panel list."); + + container.Attach(); + } + + private class Flow : FillFlowContainer { public override IEnumerable FlowingChildren => applySorting(AliveInternalChildren); - public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Score != score).Count(); + public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).Count(); - private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() - .OrderByDescending(s => s.Score.TotalScore) - .ThenBy(s => s.Score.OnlineScoreID); + private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() + .OrderByDescending(s => s.Panel.Score.TotalScore) + .ThenBy(s => s.Panel.Score.OnlineScoreID); + + protected override int Compare(Drawable x, Drawable y) + { + var tX = (ScorePanelTrackingContainer)x; + var tY = (ScorePanelTrackingContainer)y; + + int result = tY.Panel.Score.TotalScore.CompareTo(tX.Panel.Score.TotalScore); + + if (result != 0) + return result; + + if (tX.Panel.Score.OnlineScoreID == null || tY.Panel.Score.OnlineScoreID == null) + return base.Compare(x, y); + + return tX.Panel.Score.OnlineScoreID.Value.CompareTo(tY.Panel.Score.OnlineScoreID.Value); + } } private class Scroll : OsuScrollContainer diff --git a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs new file mode 100644 index 0000000000..c8010d1c32 --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Ranking +{ + /// + /// A which tracks the size of a , to which the can be added or removed. + /// + public class ScorePanelTrackingContainer : CompositeDrawable + { + /// + /// The that created this . + /// + public readonly ScorePanel Panel; + + internal ScorePanelTrackingContainer(ScorePanel panel) + { + Panel = panel; + Attach(); + } + + /// + /// Detaches the from this , removing it as a child. + /// This will continue tracking any size changes. + /// + /// If the is already detached. + public void Detach() + { + if (InternalChildren.Count == 0) + throw new InvalidOperationException("Score panel container is not attached."); + + RemoveInternal(Panel); + } + + /// + /// Attaches the to this , adding it as a child. + /// + /// If the is already attached. + public void Attach() + { + if (InternalChildren.Count > 0) + throw new InvalidOperationException("Score panel container is already attached."); + + AddInternal(Panel); + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs new file mode 100644 index 0000000000..527da429ed --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// A graph which displays the distribution of hit timing in a series of s. + /// + public class HitEventTimingDistributionGraph : CompositeDrawable + { + /// + /// The number of bins on each side of the timing distribution. + /// + private const int timing_distribution_bins = 50; + + /// + /// The total number of bins in the timing distribution, including bins on both sides and the centre bin at 0. + /// + private const int total_timing_distribution_bins = timing_distribution_bins * 2 + 1; + + /// + /// The centre bin, with a timing distribution very close to/at 0. + /// + private const int timing_distribution_centre_bin_index = timing_distribution_bins; + + /// + /// The number of data points shown on each side of the axis below the graph. + /// + private const float axis_points = 5; + + private readonly IReadOnlyList hitEvents; + + /// + /// Creates a new . + /// + /// The s to display the timing distribution of. + public HitEventTimingDistributionGraph(IReadOnlyList hitEvents) + { + this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows)).ToList(); + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEvents == null || hitEvents.Count == 0) + return; + + int[] bins = new int[total_timing_distribution_bins]; + + double binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); + + // Prevent div-by-0 by enforcing a minimum bin size + binSize = Math.Max(1, binSize); + + foreach (var e in hitEvents) + { + int binOffset = (int)(e.TimeOffset / binSize); + bins[timing_distribution_centre_bin_index + binOffset]++; + } + + int maxCount = bins.Max(); + var bars = new Drawable[total_timing_distribution_bins]; + for (int i = 0; i < bars.Length; i++) + bars[i] = new Bar { Height = Math.Max(0.05f, (float)bins[i] / maxCount) }; + + Container axisFlow; + + InternalChild = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Content = new[] + { + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] { bars } + } + }, + new Drawable[] + { + axisFlow = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } + }; + + // Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size. + double maxValue = timing_distribution_bins * binSize; + double axisValueStep = maxValue / axis_points; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "0", + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + + for (int i = 1; i <= axis_points; i++) + { + double axisValue = i * axisValueStep; + float position = (float)(axisValue / maxValue); + float alpha = 1f - position * 0.8f; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = -position / 2, + Alpha = alpha, + Text = axisValue.ToString("-0"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = position / 2, + Alpha = alpha, + Text = axisValue.ToString("+0"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + } + } + + private class Bar : CompositeDrawable + { + public Bar() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + RelativeSizeAxes = Axes.Both; + + Padding = new MarginPadding { Horizontal = 1 }; + + InternalChild = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#66FFCC") + }; + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs new file mode 100644 index 0000000000..ed98698411 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics.CodeAnalysis; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// Wraps a to add a header and suitable layout for use in . + /// + internal class StatisticContainer : CompositeDrawable + { + /// + /// Creates a new . + /// + /// The to display. + public StatisticContainer([NotNull] StatisticItem item) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 9, + Width = 4, + Colour = Color4Extensions.FromHex("#00FFAA") + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = item.Name, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + } + } + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 15 }, + Child = item.Content + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs new file mode 100644 index 0000000000..e959ed24fc --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// An item to be displayed in a row of statistics inside the results screen. + /// + public class StatisticItem + { + /// + /// The name of this item. + /// + public readonly string Name; + + /// + /// The content to be displayed. + /// + public readonly Drawable Content; + + /// + /// The of this row. This can be thought of as the column dimension of an encompassing . + /// + public readonly Dimension Dimension; + + /// + /// Creates a new , to be displayed inside a in the results screen. + /// + /// The name of the item. + /// The content to be displayed. + /// The of this item. This can be thought of as the column dimension of an encompassing . + public StatisticItem([NotNull] string name, [NotNull] Drawable content, [CanBeNull] Dimension dimension = null) + { + Name = name; + Content = content; + Dimension = dimension; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs new file mode 100644 index 0000000000..e1ca9799a3 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// A row of statistics to be displayed in the results screen. + /// + public class StatisticRow + { + /// + /// The columns of this . + /// + [ItemNotNull] + public StatisticItem[] Columns; + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs new file mode 100644 index 0000000000..7f406331cd --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Placeholders; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + public class StatisticsPanel : VisibilityContainer + { + public const float SIDE_PADDING = 30; + + public readonly Bindable Score = new Bindable(); + + protected override bool StartHidden => true; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private readonly Container content; + private readonly LoadingSpinner spinner; + + public StatisticsPanel() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Left = ScorePanel.EXPANDED_WIDTH + SIDE_PADDING * 3, + Right = SIDE_PADDING, + Top = SIDE_PADDING, + Bottom = 50 // Approximate padding to the bottom of the score panel. + }, + Children = new Drawable[] + { + content = new Container { RelativeSizeAxes = Axes.Both }, + spinner = new LoadingSpinner() + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + Score.BindValueChanged(populateStatistics, true); + } + + private CancellationTokenSource loadCancellation; + + private void populateStatistics(ValueChangedEvent score) + { + loadCancellation?.Cancel(); + loadCancellation = null; + + foreach (var child in content) + child.FadeOut(150).Expire(); + + var newScore = score.NewValue; + + if (newScore == null) + return; + + if (newScore.HitEvents == null || newScore.HitEvents.Count == 0) + content.Add(new MessagePlaceholder("Score has no statistics :(")); + else + { + spinner.Show(); + + var localCancellationSource = loadCancellation = new CancellationTokenSource(); + IBeatmap playableBeatmap = null; + + // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. + Task.Run(() => + { + playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.Beatmap).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods ?? Array.Empty()); + }, loadCancellation.Token).ContinueWith(t => Schedule(() => + { + var rows = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30, 15), + }; + + foreach (var row in newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap)) + { + rows.Add(new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + row.Columns?.Select(c => new StatisticContainer(c) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }).Cast().ToArray() + }, + ColumnDimensions = Enumerable.Range(0, row.Columns?.Length ?? 0) + .Select(i => row.Columns[i].Dimension ?? new Dimension()).ToArray(), + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + }); + } + + LoadComponentAsync(rows, d => + { + if (Score.Value != newScore) + return; + + spinner.Hide(); + content.Add(d); + }, localCancellationSource.Token); + }), localCancellationSource.Token); + } + } + + protected override bool OnClick(ClickEvent e) + { + ToggleVisibility(); + return true; + } + + protected override void PopIn() => this.FadeIn(150, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(150, Easing.OutQuint); + + protected override void Dispose(bool isDisposing) + { + loadCancellation?.Cancel(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 6f913a3177..5f6f859d66 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -95,7 +95,6 @@ namespace osu.Game.Screens.Select CarouselRoot newRoot = new CarouselRoot(this); beatmapSets.Select(createCarouselSet).Where(g => g != null).ForEach(newRoot.AddChild); - newRoot.Filter(activeCriteria); // preload drawables as the ctor overhead is quite high currently. _ = newRoot.Drawables; @@ -108,6 +107,9 @@ namespace osu.Game.Screens.Select itemsCache.Invalidate(); scrollPositionCache.Invalidate(); + // apply any pending filter operation that may have been delayed (see applyActiveCriteria's scheduling behaviour when BeatmapSetsLoaded is false). + FlushPendingFilterOperations(); + // Run on late scheduler want to ensure this runs after all pending UpdateBeatmapSet / RemoveBeatmapSet operations are run. SchedulerAfterChildren.Add(() => { @@ -321,6 +323,9 @@ namespace osu.Game.Screens.Select /// True if a selection could be made, else False. public bool SelectNextRandom() { + if (!AllowSelection) + return false; + var visibleSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); if (!visibleSets.Any()) return false; @@ -427,7 +432,19 @@ namespace osu.Game.Screens.Select private void applyActiveCriteria(bool debounce, bool alwaysResetScrollPosition = true) { - if (root.Children.Any() != true) return; + PendingFilter?.Cancel(); + PendingFilter = null; + + if (debounce) + PendingFilter = Scheduler.AddDelayed(perform, 250); + else + { + // if initial load is not yet finished, this will be run inline in loadBeatmapSets to ensure correct order of operation. + if (!BeatmapSetsLoaded) + PendingFilter = Schedule(perform); + else + perform(); + } void perform() { @@ -439,14 +456,6 @@ namespace osu.Game.Screens.Select if (alwaysResetScrollPosition || !scroll.UserScrolling) ScrollToSelected(); } - - PendingFilter?.Cancel(); - PendingFilter = null; - - if (debounce) - PendingFilter = Scheduler.AddDelayed(perform, 250); - else - perform(); } private float? scrollTarget; diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 7a8a1593b9..27ce9e82dd 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -155,7 +155,6 @@ namespace osu.Game.Screens.Select var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); CacheDrawnFrameBuffer = true; - RedrawOnScale = false; RelativeSizeAxes = Axes.Both; diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 3e4798a812..c559b4f8f5 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Threading; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -41,6 +43,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private BeatmapSetOverlay beatmapOverlay { get; set; } + [Resolved] + private BeatmapDifficultyManager difficultyManager { get; set; } + + private IBindable starDifficultyBindable; + private CancellationTokenSource starDifficultyCancellationSource; + public DrawableCarouselBeatmap(CarouselBeatmap panel) : base(panel) { @@ -137,7 +145,6 @@ namespace osu.Game.Screens.Select.Carousel }, starCounter = new StarCounter { - Current = (float)beatmap.StarDifficulty, Scale = new Vector2(0.8f), } } @@ -181,6 +188,16 @@ namespace osu.Game.Screens.Select.Carousel if (Item.State.Value != CarouselItemState.Collapsed && Alpha == 0) starCounter.ReplayAnimation(); + starDifficultyCancellationSource?.Cancel(); + + // Only compute difficulty when the item is visible. + if (Item.State.Value != CarouselItemState.Collapsed) + { + // We've potentially cancelled the computation above so a new bindable is required. + starDifficultyBindable = difficultyManager.GetBindableDifficulty(beatmap, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); + starDifficultyBindable.BindValueChanged(d => starCounter.Current = (float)d.NewValue.Stars, true); + } + base.ApplyState(); } @@ -205,5 +222,11 @@ namespace osu.Game.Screens.Select.Carousel return items.ToArray(); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + starDifficultyCancellationSource?.Cancel(); + } } } diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index 02822ea608..44c328187f 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -14,10 +14,12 @@ using osu.Framework.Bindables; using System.Collections.Generic; using osu.Game.Rulesets.Mods; using System.Linq; +using System.Threading; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Overlays.Settings; +using osu.Game.Rulesets; namespace osu.Game.Screens.Select.Details { @@ -26,6 +28,12 @@ namespace osu.Game.Screens.Select.Details [Resolved] private IBindable> mods { get; set; } + [Resolved] + private IBindable ruleset { get; set; } + + [Resolved] + private BeatmapDifficultyManager difficultyManager { get; set; } + protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; @@ -71,6 +79,7 @@ namespace osu.Game.Screens.Select.Details { base.LoadComplete(); + ruleset.BindValueChanged(_ => updateStatistics()); mods.BindValueChanged(modsChanged, true); } @@ -132,11 +141,39 @@ namespace osu.Game.Screens.Select.Details break; } - starDifficulty.Value = ((float)(Beatmap?.StarDifficulty ?? 0), null); - HpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate); Accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty); ApproachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate); + + updateStarDifficulty(); + } + + private IBindable normalStarDifficulty; + private IBindable moddedStarDifficulty; + private CancellationTokenSource starDifficultyCancellationSource; + + private void updateStarDifficulty() + { + starDifficultyCancellationSource?.Cancel(); + + if (Beatmap == null) + return; + + starDifficultyCancellationSource = new CancellationTokenSource(); + + normalStarDifficulty = difficultyManager.GetBindableDifficulty(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); + moddedStarDifficulty = difficultyManager.GetBindableDifficulty(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + + normalStarDifficulty.BindValueChanged(_ => updateDisplay()); + moddedStarDifficulty.BindValueChanged(_ => updateDisplay(), true); + + void updateDisplay() => starDifficulty.Value = ((float)normalStarDifficulty.Value.Stars, (float)moddedStarDifficulty.Value.Stars); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + starDifficultyCancellationSource?.Cancel(); } public class StatisticRow : Container, IHasAccentColour diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index d613ce649a..e111ec4b15 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -2,21 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Select.Filter; -using Container = osu.Framework.Graphics.Containers.Container; using osu.Framework.Graphics.Shapes; -using osu.Game.Configuration; -using osu.Game.Rulesets; using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Screens.Select.Filter; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Select { @@ -26,9 +25,7 @@ namespace osu.Game.Screens.Select public Action FilterChanged; - private readonly OsuTabControl sortTabs; - - private readonly TabControl groupTabs; + private OsuTabControl sortTabs; private Bindable sortMode; @@ -56,19 +53,39 @@ namespace osu.Game.Screens.Select return criteria; } - private readonly SeekLimitedSearchTextBox searchTextBox; + private SeekLimitedSearchTextBox searchTextBox; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => - base.ReceivePositionalInputAt(screenSpacePos) || groupTabs.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); + base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); - public FilterControl() + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuColour colours, IBindable parentRuleset, OsuConfigManager config) { + config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted); + showConverted.ValueChanged += _ => updateCriteria(); + + config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars); + minimumStars.ValueChanged += _ => updateCriteria(); + + config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars); + maximumStars.ValueChanged += _ => updateCriteria(); + + ruleset.BindTo(parentRuleset); + ruleset.BindValueChanged(_ => updateCriteria()); + + sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); + groupMode = config.GetBindable(OsuSetting.SongSelectGroupingMode); + + groupMode.BindValueChanged(_ => updateCriteria()); + sortMode.BindValueChanged(_ => updateCriteria()); + Children = new Drawable[] { - Background = new Box + new Box { Colour = Color4.Black, Alpha = 0.8f, + Width = 2, RelativeSizeAxes = Axes.Both, }, new Container @@ -96,33 +113,35 @@ namespace osu.Game.Screens.Select Direction = FillDirection.Horizontal, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Spacing = new Vector2(OsuTabControl.HORIZONTAL_SPACING, 0), Children = new Drawable[] { - groupTabs = new OsuTabControl + new OsuTabControlCheckbox { - RelativeSizeAxes = Axes.X, - Height = 24, - Width = 0.5f, - AutoSort = true, + Text = "Show converted", + Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, }, - //spriteText = new OsuSpriteText - //{ - // Font = @"Exo2.0-Bold", - // Text = "Sort results by", - // Size = 14, - // Margin = new MarginPadding - // { - // Top = 5, - // Bottom = 5 - // }, - //}, sortTabs = new OsuTabControl { RelativeSizeAxes = Axes.X, Width = 0.5f, Height = 24, AutoSort = true, - } + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AccentColour = colours.GreenLight, + Current = { BindTarget = sortMode } + }, + new OsuSpriteText + { + Text = "Sort by", + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding(5), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, } }, } @@ -131,8 +150,7 @@ namespace osu.Game.Screens.Select searchTextBox.Current.ValueChanged += _ => FilterChanged?.Invoke(CreateCriteria()); - groupTabs.PinItem(GroupMode.All); - groupTabs.PinItem(GroupMode.RecentlyPlayed); + updateCriteria(); } public void Deactivate() @@ -156,37 +174,6 @@ namespace osu.Game.Screens.Select private readonly Bindable minimumStars = new BindableDouble(); private readonly Bindable maximumStars = new BindableDouble(); - public readonly Box Background; - - [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, IBindable parentRuleset, OsuConfigManager config) - { - sortTabs.AccentColour = colours.GreenLight; - - config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted); - showConverted.ValueChanged += _ => updateCriteria(); - - config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars); - minimumStars.ValueChanged += _ => updateCriteria(); - - config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars); - maximumStars.ValueChanged += _ => updateCriteria(); - - ruleset.BindTo(parentRuleset); - ruleset.BindValueChanged(_ => updateCriteria()); - - sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); - groupMode = config.GetBindable(OsuSetting.SongSelectGroupingMode); - - sortTabs.Current.BindTo(sortMode); - groupTabs.Current.BindTo(groupMode); - - groupMode.BindValueChanged(_ => updateCriteria()); - sortMode.BindValueChanged(_ => updateCriteria()); - - updateCriteria(); - } - private void updateCriteria() => FilterChanged?.Invoke(CreateCriteria()); protected override bool OnClick(ClickEvent e) => true; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index d613b0ae8d..74a5ee8309 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -173,7 +173,6 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Height = FilterControl.HEIGHT, FilterChanged = ApplyFilterToCarousel, - Background = { Width = 2 }, }, new GridContainer // used for max width implementation { @@ -600,12 +599,6 @@ namespace osu.Game.Screens.Select public override bool OnExiting(IScreen next) { - if (ModSelect.State.Value == Visibility.Visible) - { - ModSelect.Hide(); - return true; - } - if (base.OnExiting(next)) return true; @@ -621,6 +614,17 @@ namespace osu.Game.Screens.Select return false; } + public override bool OnBackButton() + { + if (ModSelect.State.Value == Visibility.Visible) + { + ModSelect.Hide(); + return true; + } + + return false; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 6b4af21b37..61d0112c89 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osuTK.Graphics; @@ -21,7 +22,7 @@ namespace osu.Game.Skinning public override Drawable GetDrawableComponent(ISkinComponent component) => null; - public override Texture GetTexture(string componentName) => null; + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public override SampleChannel GetSample(ISampleInfo sampleInfo) => null; diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs index 4ed5ef64c3..7e52bb8176 100644 --- a/osu.Game/Skinning/IAnimationTimeReference.cs +++ b/osu.Game/Skinning/IAnimationTimeReference.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Timing; namespace osu.Game.Skinning @@ -11,7 +12,7 @@ namespace osu.Game.Skinning /// /// /// This should not be used to start an animation immediately at the current time. - /// To do so, use with startAtCurrentTime = true instead. + /// To do so, use with startAtCurrentTime = true instead. /// [Cached] public interface IAnimationTimeReference diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index cb2a379b8e..5abd963773 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; @@ -29,7 +30,17 @@ namespace osu.Game.Skinning /// The requested texture. /// A matching texture, or null if unavailable. [CanBeNull] - Texture GetTexture(string componentName); + Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + /// + /// Retrieve a . + /// + /// The requested texture. + /// The texture wrap mode in horizontal direction. + /// The texture wrap mode in vertical direction. + /// A matching texture, or null if unavailable. + [CanBeNull] + Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); /// /// Retrieve a . diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 87bca856a3..d647bc4a2d 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -14,6 +14,7 @@ namespace osu.Game.Skinning public class LegacyBeatmapSkin : LegacySkin { protected override bool AllowManiaSkin => false; + protected override bool UseCustomSampleBanks => true; public LegacyBeatmapSkin(BeatmapInfo beatmap, IResourceStore storage, AudioManager audioManager) : base(createSkinInfo(beatmap), new LegacySkinResourceStore(beatmap.BeatmapSet, storage), audioManager, beatmap.Path) diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 0806676fde..aebc229f7c 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -115,6 +115,7 @@ namespace osu.Game.Skinning case string _ when pair.Key.StartsWith("NoteImage"): case string _ when pair.Key.StartsWith("KeyImage"): case string _ when pair.Key.StartsWith("Hit"): + case string _ when pair.Key.StartsWith("Stage"): currentConfig.ImageLookups[pair.Key] = pair.Value; break; } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 0b2b723440..02d07eee45 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -11,6 +11,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Audio; @@ -37,6 +38,12 @@ namespace osu.Game.Skinning protected virtual bool AllowManiaSkin => hasKeyTexture.Value; + /// + /// Whether this skin can use samples with a custom bank (custom sample set in stable terminology). + /// Added in order to match sample lookup logic from stable (in stable, only the beatmap skin could use samples with a custom sample bank). + /// + protected virtual bool UseCustomSampleBanks => false; + public new LegacySkinConfiguration Configuration { get => base.Configuration as LegacySkinConfiguration; @@ -119,15 +126,6 @@ namespace osu.Game.Skinning break; - case LegacySkinConfiguration.LegacySetting legacy: - switch (legacy) - { - case LegacySkinConfiguration.LegacySetting.Version: - return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION)); - } - - break; - case SkinCustomColourLookup customColour: return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); @@ -141,28 +139,11 @@ namespace osu.Game.Skinning break; + case LegacySkinConfiguration.LegacySetting legacy: + return legacySettingLookup(legacy); + default: - // handles lookups like GlobalSkinConfiguration - - try - { - if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out var val)) - { - // special case for handling skins which use 1 or 0 to signify a boolean state. - if (typeof(TValue) == typeof(bool)) - val = val == "1" ? "true" : "false"; - - var bindable = new Bindable(); - if (val != null) - bindable.Parse(val); - return bindable; - } - } - catch - { - } - - break; + return genericLookup(lookup); } return null; @@ -250,6 +231,15 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.RightStageImage: return SkinUtils.As(getManiaImage(existing, "StageRight")); + case LegacyManiaSkinConfigurationLookups.BottomStageImage: + return SkinUtils.As(getManiaImage(existing, "StageBottom")); + + case LegacyManiaSkinConfigurationLookups.LightImage: + return SkinUtils.As(getManiaImage(existing, "StageLight")); + + case LegacyManiaSkinConfigurationLookups.HitTargetImage: + return SkinUtils.As(getManiaImage(existing, "StageHint")); + case LegacyManiaSkinConfigurationLookups.LeftLineWidth: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); @@ -276,6 +266,43 @@ namespace osu.Game.Skinning private IBindable getManiaImage(LegacyManiaSkinConfiguration source, string lookup) => source.ImageLookups.TryGetValue(lookup, out var image) ? new Bindable(image) : null; + [CanBeNull] + private IBindable legacySettingLookup(LegacySkinConfiguration.LegacySetting legacySetting) + { + switch (legacySetting) + { + case LegacySkinConfiguration.LegacySetting.Version: + return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION)); + + default: + return genericLookup(legacySetting); + } + } + + [CanBeNull] + private IBindable genericLookup(TLookup lookup) + { + try + { + if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out var val)) + { + // special case for handling skins which use 1 or 0 to signify a boolean state. + if (typeof(TValue) == typeof(bool)) + val = val == "1" ? "true" : "false"; + + var bindable = new Bindable(); + if (val != null) + bindable.Parse(val); + return bindable; + } + } + catch + { + } + + return null; + } + public override Drawable GetDrawableComponent(ISkinComponent component) { switch (component) @@ -302,17 +329,17 @@ namespace osu.Game.Skinning return this.GetAnimation(component.LookupName, false, false); } - public override Texture GetTexture(string componentName) + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { foreach (var name in getFallbackNames(componentName)) { float ratio = 2; - var texture = Textures?.Get($"{name}@2x"); + var texture = Textures?.Get($"{name}@2x", wrapModeS, wrapModeT); if (texture == null) { ratio = 1; - texture = Textures?.Get(name); + texture = Textures?.Get(name, wrapModeS, wrapModeT); } if (texture == null) @@ -327,7 +354,12 @@ namespace osu.Game.Skinning public override SampleChannel GetSample(ISampleInfo sampleInfo) { - foreach (var lookup in sampleInfo.LookupNames) + var lookupNames = sampleInfo.LookupNames; + + if (sampleInfo is HitSampleInfo hitSample) + lookupNames = getLegacyLookupNames(hitSample); + + foreach (var lookup in lookupNames) { var sample = Samples?.Get(lookup); @@ -335,10 +367,6 @@ namespace osu.Game.Skinning return sample; } - if (sampleInfo is HitSampleInfo hsi) - // Try fallback to non-bank samples. - return Samples?.Get(hsi.Name); - return null; } @@ -351,5 +379,23 @@ namespace osu.Game.Skinning string lastPiece = componentName.Split('/').Last(); yield return componentName.StartsWith("Gameplay/taiko/") ? "taiko-" + lastPiece : lastPiece; } + + private IEnumerable getLegacyLookupNames(HitSampleInfo hitSample) + { + var lookupNames = hitSample.LookupNames; + + if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix)) + // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin. + // using .EndsWith() is intentional as it ensures parity in all edge cases + // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not). + lookupNames = hitSample.LookupNames.Where(name => !name.EndsWith(hitSample.Suffix)); + + // also for compatibility, try falling back to non-bank samples (so-called "universal" samples) as the last resort. + // going forward specifying banks shall always be required, even for elements that wouldn't require it on stable, + // which is why this is done locally here. + lookupNames = lookupNames.Append(hitSample.Name); + + return lookupNames; + } } } diff --git a/osu.Game/Skinning/LegacySkinConfiguration.cs b/osu.Game/Skinning/LegacySkinConfiguration.cs index 027f5b8883..41b7aea34b 100644 --- a/osu.Game/Skinning/LegacySkinConfiguration.cs +++ b/osu.Game/Skinning/LegacySkinConfiguration.cs @@ -15,6 +15,8 @@ namespace osu.Game.Skinning public enum LegacySetting { Version, + AnimationFramerate, + LayeredHitSounds, } } } diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index 549571dec4..bb46dc8b9f 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -6,8 +6,10 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Skinning { @@ -15,6 +17,11 @@ namespace osu.Game.Skinning { public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null) + => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength); + + public static Drawable GetAnimation(this ISkin source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, + string animationSeparator = "-", + bool startAtCurrentTime = true, double? frameLength = null) { Texture texture; @@ -38,7 +45,7 @@ namespace osu.Game.Skinning } // if an animation was not allowed or not found, fall back to a sprite retrieval. - if ((texture = source.GetTexture(componentName)) != null) + if ((texture = source.GetTexture(componentName, wrapModeS, wrapModeT)) != null) return new Sprite { Texture = texture }; return null; @@ -47,7 +54,7 @@ namespace osu.Game.Skinning { for (int i = 0; true; i++) { - if ((texture = source.GetTexture($"{componentName}{animationSeparator}{i}")) == null) + if ((texture = source.GetTexture($"{componentName}{animationSeparator}{i}", wrapModeS, wrapModeT)) == null) break; yield return texture; @@ -83,7 +90,7 @@ namespace osu.Game.Skinning { if (applyConfigFrameRate) { - var iniRate = source.GetConfig(GlobalSkinConfiguration.AnimationFramerate); + var iniRate = source.GetConfig(LegacySetting.AnimationFramerate); if (iniRate?.Value > 0) return 1000f / iniRate.Value; diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index 94a7a32f05..ebc4757e75 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -4,9 +4,11 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Rulesets.Objects.Legacy; +using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Skinning { @@ -27,14 +29,17 @@ namespace osu.Game.Skinning public abstract Drawable GetDrawableComponent(ISkinComponent component); - public Texture GetTexture(string componentName) => Source.GetTexture(componentName); + public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + => Source.GetTexture(componentName, wrapModeS, wrapModeT); public virtual SampleChannel GetSample(ISampleInfo sampleInfo) { if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) return Source.GetSample(sampleInfo); - var playLayeredHitSounds = GetConfig(GlobalSkinConfiguration.LayeredHitSounds); + var playLayeredHitSounds = GetConfig(LegacySetting.LayeredHitSounds); if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) return new SampleChannelVirtual(); diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index fa4aebd8a5..4b0cf02c0a 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; @@ -20,7 +21,9 @@ namespace osu.Game.Skinning public abstract SampleChannel GetSample(ISampleInfo sampleInfo); - public abstract Texture GetTexture(string componentName); + public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + public abstract Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); public abstract IBindable GetConfig(TLookup lookup); diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index d65c74ef62..e1f713882a 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -13,6 +13,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -130,7 +131,7 @@ namespace osu.Game.Skinning public Drawable GetDrawableComponent(ISkinComponent component) => CurrentSkin.Value.GetDrawableComponent(component); - public Texture GetTexture(string componentName) => CurrentSkin.Value.GetTexture(componentName); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => CurrentSkin.Value.GetTexture(componentName, wrapModeS, wrapModeT); public SampleChannel GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index 1c01bbf1ab..adf62ed452 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -7,6 +7,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; @@ -47,13 +48,13 @@ namespace osu.Game.Skinning return fallbackSource?.GetDrawableComponent(component); } - public Texture GetTexture(string componentName) + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { Texture sourceTexture; - if (AllowTextureLookup(componentName) && (sourceTexture = skin?.GetTexture(componentName)) != null) + if (AllowTextureLookup(componentName) && (sourceTexture = skin?.GetTexture(componentName, wrapModeS, wrapModeT)) != null) return sourceTexture; - return fallbackSource?.GetTexture(componentName); + return fallbackSource?.GetTexture(componentName, wrapModeS, wrapModeT); } public SampleChannel GetSample(ISampleInfo sampleInfo) diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index 0f0d3da5aa..d9a5036649 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osuTK; namespace osu.Game.Skinning @@ -50,6 +51,11 @@ namespace osu.Game.Skinning RelativeSizeAxes = Axes.Both; } + /// + /// Seeks to the 0-th frame if the content of this is an . + /// + public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0); + private readonly Func createDefault; private readonly Cached scaling = new Cached(); diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index 30320c89a6..f19aaee821 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -4,12 +4,16 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Transforms; using osu.Game.Audio; +using osu.Game.Screens.Play; namespace osu.Game.Skinning { @@ -20,6 +24,13 @@ namespace osu.Game.Skinning [Resolved] private ISampleStore samples { get; set; } + private bool requestedPlaying; + + public override bool RemoveWhenNotAlive => false; + public override bool RemoveCompletedTransforms => false; + + private readonly AudioContainer samplesContainer; + public SkinnableSound(ISampleInfo hitSamples) : this(new[] { hitSamples }) { @@ -31,18 +42,29 @@ namespace osu.Game.Skinning InternalChild = samplesContainer = new AudioContainer(); } + private Bindable gameplayClockPaused; + + [BackgroundDependencyLoader(true)] + private void load(GameplayClock gameplayClock) + { + // if in a gameplay context, pause sample playback when gameplay is paused. + gameplayClockPaused = gameplayClock?.IsPaused.GetBoundCopy(); + gameplayClockPaused?.BindValueChanged(paused => + { + if (requestedPlaying) + { + if (paused.NewValue) + stop(); + // it's not easy to know if a sample has finished playing (to end). + // to keep things simple only resume playing looping samples. + else if (Looping) + play(); + } + }); + } + private bool looping; - private readonly AudioContainer samplesContainer; - - public BindableNumber Volume => samplesContainer.Volume; - - public BindableNumber Balance => samplesContainer.Balance; - - public BindableNumber Frequency => samplesContainer.Frequency; - - public BindableNumber Tempo => samplesContainer.Tempo; - public bool Looping { get => looping; @@ -56,15 +78,31 @@ namespace osu.Game.Skinning } } - public void Play() => samplesContainer.ForEach(c => + public void Play() { - if (c.AggregateVolume.Value > 0) - c.Play(); - }); + requestedPlaying = true; + play(); + } - public void Stop() => samplesContainer.ForEach(c => c.Stop()); + private void play() + { + samplesContainer.ForEach(c => + { + if (c.AggregateVolume.Value > 0) + c.Play(); + }); + } - public override bool IsPresent => Scheduler.HasPendingTasks; + public void Stop() + { + requestedPlaying = false; + stop(); + } + + private void stop() + { + samplesContainer.ForEach(c => c.Stop()); + } protected override void SkinChanged(ISkinSource skin, bool allowFallback) { @@ -91,6 +129,51 @@ namespace osu.Game.Skinning }).Where(c => c != null); samplesContainer.ChildrenEnumerable = channels.Select(c => new DrawableSample(c)); + + if (requestedPlaying) + Play(); } + + #region Re-expose AudioContainer + + public BindableNumber Volume => samplesContainer.Volume; + + public BindableNumber Balance => samplesContainer.Balance; + + public BindableNumber Frequency => samplesContainer.Frequency; + + public BindableNumber Tempo => samplesContainer.Tempo; + + public bool IsPlaying => samplesContainer.Any(s => s.Playing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence> VolumeTo(double newVolume, double duration = 0, Easing easing = Easing.None) => + samplesContainer.VolumeTo(newVolume, duration, easing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence> BalanceTo(double newBalance, double duration = 0, Easing easing = Easing.None) => + samplesContainer.BalanceTo(newBalance, duration, easing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence> FrequencyTo(double newFrequency, double duration = 0, Easing easing = Easing.None) => + samplesContainer.FrequencyTo(newFrequency, duration, easing); + + /// + /// Smoothly adjusts over time. + /// + /// A to which further transforms can be added. + public TransformSequence> TempoTo(double newTempo, double duration = 0, Easing easing = Easing.None) => + samplesContainer.TempoTo(newTempo, duration, easing); + + #endregion } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs index a85936edf7..4ea582ca4a 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -55,10 +55,11 @@ namespace osu.Game.Storyboards.Drawables if (video == null) return; - video.PlaybackPosition = Clock.CurrentTime - Video.StartTime; - - using (video.BeginAbsoluteSequence(0)) + using (video.BeginAbsoluteSequence(Video.StartTime)) + { + Schedule(() => video.PlaybackPosition = Time.Current - Video.StartTime); video.FadeIn(500); + } } } } diff --git a/osu.Game/Tests/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs index 1193a29d70..31cced6ce4 100644 --- a/osu.Game/Tests/TestScoreInfo.cs +++ b/osu.Game/Tests/TestScoreInfo.cs @@ -37,6 +37,8 @@ namespace osu.Game.Tests Statistics[HitResult.Meh] = 50; Statistics[HitResult.Good] = 100; Statistics[HitResult.Great] = 300; + + Position = 1; } private class TestModHardRock : ModHardRock diff --git a/osu.Game/Tests/Visual/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/MultiplayerTestScene.cs index ffb431b4d3..4d073f16f4 100644 --- a/osu.Game/Tests/Visual/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/MultiplayerTestScene.cs @@ -10,7 +10,7 @@ namespace osu.Game.Tests.Visual public abstract class MultiplayerTestScene : ScreenTestScene { [Cached] - private readonly Bindable currentRoom = new Bindable(new Room()); + private readonly Bindable currentRoom = new Bindable(); protected Room Room { diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index cb9ed40b00..866fc215d6 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -305,8 +305,10 @@ namespace osu.Game.Tests.Visual { double refTime = referenceClock.CurrentTime; - if (lastReferenceTime.HasValue) - accumulated += (refTime - lastReferenceTime.Value) * Rate; + double? lastRefTime = lastReferenceTime; + + if (lastRefTime != null) + accumulated += (refTime - lastRefTime.Value) * Rate; lastReferenceTime = refTime; } diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index ea7cdaaac6..81c13112d0 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -9,6 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; @@ -157,7 +158,7 @@ namespace osu.Game.Tests.Visual this.extrapolateAnimations = extrapolateAnimations; } - public override Texture GetTexture(string componentName) + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { // extrapolate frames to test longer animations if (extrapolateAnimations) @@ -165,10 +166,10 @@ namespace osu.Game.Tests.Visual var match = Regex.Match(componentName, "-([0-9]*)"); if (match.Length > 0 && int.TryParse(match.Groups[1].Value, out var number) && number < 60) - return base.GetTexture(componentName.Replace($"-{number}", $"-{number % 2}")); + return base.GetTexture(componentName.Replace($"-{number}", $"-{number % 2}"), wrapModeS, wrapModeT); } - return base.GetTexture(componentName); + return base.GetTexture(componentName, wrapModeS, wrapModeT); } } } diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs new file mode 100644 index 0000000000..2604815751 --- /dev/null +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK; +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.Framework.Graphics.Sprites; +using osu.Game.Users.Drawables; +using osu.Framework.Input.Events; + +namespace osu.Game.Users +{ + public abstract class ExtendedUserPanel : UserPanel + { + public readonly Bindable Status = new Bindable(); + + public readonly IBindable Activity = new Bindable(); + + protected TextFlowContainer LastVisitMessage { get; private set; } + + private SpriteIcon statusIcon; + private OsuSpriteText statusMessage; + + protected ExtendedUserPanel(User user) + : base(user) + { + } + + [BackgroundDependencyLoader] + private void load() + { + BorderColour = ColourProvider?.Light1 ?? Colours.GreyVioletLighter; + + Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); + Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Status.TriggerChange(); + + // Colour should be applied immediately on first load. + statusIcon.FinishTransforms(); + } + + protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar + { + User = User, + OpenOnClick = { Value = false } + }; + + protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) + { + Size = new Vector2(39, 26) + }; + + protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon + { + Icon = FontAwesome.Regular.Circle, + Size = new Vector2(25) + }; + + protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) + { + var statusContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical + }; + + var alignment = rightAlignedChildren ? Anchor.CentreRight : Anchor.CentreLeft; + + statusContainer.Add(LastVisitMessage = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)).With(text => + { + text.Anchor = alignment; + text.Origin = alignment; + text.AutoSizeAxes = Axes.Both; + text.Alpha = 0; + + if (User.LastVisit.HasValue) + { + text.AddText(@"Last seen "); + text.AddText(new DrawableDate(User.LastVisit.Value, italic: false) + { + Shadow = false + }); + } + })); + + statusContainer.Add(statusMessage = new OsuSpriteText + { + Anchor = alignment, + Origin = alignment, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }); + + return statusContainer; + } + + private void displayStatus(UserStatus status, UserActivity activity = null) + { + if (status != null) + { + LastVisitMessage.FadeTo(status is UserStatusOffline && User.LastVisit.HasValue ? 1 : 0); + + // Set status message based on activity (if we have one) and status is not offline + if (activity != null && !(status is UserStatusOffline)) + { + statusMessage.Text = activity.Status; + statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); + return; + } + + // Otherwise use only status + statusMessage.Text = status.Message; + statusIcon.FadeColour(status.GetAppropriateColour(Colours), 500, Easing.OutQuint); + + return; + } + + // Fallback to web status if local one is null + if (User.IsOnline) + { + Status.Value = new UserStatusOnline(); + return; + } + + Status.Value = new UserStatusOffline(); + } + + protected override bool OnHover(HoverEvent e) + { + BorderThickness = 2; + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + BorderThickness = 0; + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Users/UserBrickPanel.cs b/osu.Game/Users/UserBrickPanel.cs new file mode 100644 index 0000000000..9ca7768187 --- /dev/null +++ b/osu.Game/Users/UserBrickPanel.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Users +{ + public class UserBrickPanel : UserPanel + { + public UserBrickPanel(User user) + : base(user) + { + AutoSizeAxes = Axes.Both; + CornerRadius = 6; + } + + [BackgroundDependencyLoader] + private void load() + { + Background.FadeTo(0.2f); + } + + protected override Drawable CreateLayout() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Margin = new MarginPadding + { + Horizontal = 10, + Vertical = 3, + }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + Width = 4, + Height = 13, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = string.IsNullOrEmpty(User.Colour) ? Color4Extensions.FromHex("0087ca") : Color4Extensions.FromHex(User.Colour) + } + }, + CreateUsername().With(u => + { + u.Anchor = Anchor.CentreLeft; + u.Origin = Anchor.CentreLeft; + u.Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold); + }) + } + }; + } +} diff --git a/osu.Game/Users/UserCoverBackground.cs b/osu.Game/Users/UserCoverBackground.cs index 748d9bd939..34bbf6892e 100644 --- a/osu.Game/Users/UserCoverBackground.cs +++ b/osu.Game/Users/UserCoverBackground.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -23,6 +24,16 @@ namespace osu.Game.Users protected override Drawable CreateDrawable(User user) => new Cover(user); + protected override double LoadDelay => 300; + + /// + /// Delay before the background is unloaded while off-screen. + /// + protected virtual double UnloadDelay => 5000; + + protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) + => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); + [LongRunningLoad] private class Cover : CompositeDrawable { diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs index e62a834d6d..44dcbc305d 100644 --- a/osu.Game/Users/UserGridPanel.cs +++ b/osu.Game/Users/UserGridPanel.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Users { - public class UserGridPanel : UserPanel + public class UserGridPanel : ExtendedUserPanel { private const int margin = 10; diff --git a/osu.Game/Users/UserListPanel.cs b/osu.Game/Users/UserListPanel.cs index 1c3ae20577..9c95eff739 100644 --- a/osu.Game/Users/UserListPanel.cs +++ b/osu.Game/Users/UserListPanel.cs @@ -12,7 +12,7 @@ using osu.Game.Overlays.Profile.Header.Components; namespace osu.Game.Users { - public class UserListPanel : UserPanel + public class UserListPanel : ExtendedUserPanel { public UserListPanel(User user) : base(user) diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 6f59f9e443..57a87a713d 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -2,11 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -14,11 +11,8 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Containers; -using osu.Game.Users.Drawables; using JetBrains.Annotations; -using osu.Framework.Input.Events; namespace osu.Game.Users { @@ -26,20 +20,11 @@ namespace osu.Game.Users { public readonly User User; - public readonly Bindable Status = new Bindable(); - - public readonly IBindable Activity = new Bindable(); - public new Action Action; protected Action ViewProfile { get; private set; } - protected DelayedLoadUnloadWrapper Background { get; private set; } - - protected TextFlowContainer LastVisitMessage { get; private set; } - - private SpriteIcon statusIcon; - private OsuSpriteText statusMessage; + protected Drawable Background { get; private set; } protected UserPanel(User user) { @@ -53,42 +38,33 @@ namespace osu.Game.Users private UserProfileOverlay profileOverlay { get; set; } [Resolved(canBeNull: true)] - private OverlayColourProvider colourProvider { get; set; } + protected OverlayColourProvider ColourProvider { get; private set; } [Resolved] - private OsuColour colours { get; set; } + protected OsuColour Colours { get; private set; } [BackgroundDependencyLoader] private void load() { Masking = true; - BorderColour = colourProvider?.Light1 ?? colours.GreyVioletLighter; AddRange(new[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colourProvider?.Background5 ?? colours.Gray1 + Colour = ColourProvider?.Background5 ?? Colours.Gray1 }, - Background = new DelayedLoadUnloadWrapper(() => new UserCoverBackground + Background = new UserCoverBackground { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, User = User, - }, 300, 5000) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, }, CreateLayout() }); - Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); - Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); - base.Action = ViewProfile = () => { Action?.Invoke(); @@ -96,41 +72,9 @@ namespace osu.Game.Users }; } - protected override void LoadComplete() - { - base.LoadComplete(); - Status.TriggerChange(); - - // Colour should be applied immediately on first load. - statusIcon.FinishTransforms(); - } - - protected override bool OnHover(HoverEvent e) - { - BorderThickness = 2; - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - BorderThickness = 0; - base.OnHoverLost(e); - } - [NotNull] protected abstract Drawable CreateLayout(); - protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar - { - User = User, - OpenOnClick = { Value = false } - }; - - protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) - { - Size = new Vector2(39, 26) - }; - protected OsuSpriteText CreateUsername() => new OsuSpriteText { Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), @@ -138,80 +82,6 @@ namespace osu.Game.Users Text = User.Username, }; - protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon - { - Icon = FontAwesome.Regular.Circle, - Size = new Vector2(25) - }; - - protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) - { - var statusContainer = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical - }; - - var alignment = rightAlignedChildren ? Anchor.CentreRight : Anchor.CentreLeft; - - statusContainer.Add(LastVisitMessage = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)).With(text => - { - text.Anchor = alignment; - text.Origin = alignment; - text.AutoSizeAxes = Axes.Both; - text.Alpha = 0; - - if (User.LastVisit.HasValue) - { - text.AddText(@"Last seen "); - text.AddText(new DrawableDate(User.LastVisit.Value, italic: false) - { - Shadow = false - }); - } - })); - - statusContainer.Add(statusMessage = new OsuSpriteText - { - Anchor = alignment, - Origin = alignment, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) - }); - - return statusContainer; - } - - private void displayStatus(UserStatus status, UserActivity activity = null) - { - if (status != null) - { - LastVisitMessage.FadeTo(status is UserStatusOffline && User.LastVisit.HasValue ? 1 : 0); - - // Set status message based on activity (if we have one) and status is not offline - if (activity != null && !(status is UserStatusOffline)) - { - statusMessage.Text = activity.Status; - statusIcon.FadeColour(activity.GetAppropriateColour(colours), 500, Easing.OutQuint); - return; - } - - // Otherwise use only status - statusMessage.Text = status.Message; - statusIcon.FadeColour(status.GetAppropriateColour(colours), 500, Easing.OutQuint); - - return; - } - - // Fallback to web status if local one is null - if (User.IsOnline) - { - Status.Value = new UserStatusOnline(); - return; - } - - Status.Value = new UserStatusOffline(); - } - public MenuItem[] ContextMenuItems => new MenuItem[] { new OsuMenuItem("View Profile", MenuItemType.Highlighted, ViewProfile), diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 26d81a1004..18c3052ca3 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -24,10 +24,10 @@ - - - - + + + + diff --git a/osu.iOS.props b/osu.iOS.props index 72f09ee287..b034253d88 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -80,8 +80,8 @@ - - + + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 85d5fce29a..29ca385275 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -783,6 +783,7 @@ See the LICENCE file in the repository root for full licence text. True True True + TestFolder True True o!f – Object Initializer: Anchor&Origin