diff --git a/.github/ISSUE_TEMPLATE/01-bug-issues.md b/.github/ISSUE_TEMPLATE/01-bug-issues.md index e45893b97a..7026179259 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-issues.md +++ b/.github/ISSUE_TEMPLATE/01-bug-issues.md @@ -25,6 +25,6 @@ Please check: *please attach logs here, which are located at:* - `%AppData%/osu/logs` *(on Windows),* - `~/.local/share/osu/logs` *(on Linux & macOS).* -- `Android/Data/sh.ppy.osulazer/logs` *(on Android)*, +- `Android/data/sh.ppy.osulazer/files/logs` *(on Android)*, - on iOS they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) --> diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..9e9af23b27 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: +- package-ecosystem: nuget + directory: "/" + schedule: + interval: monthly + time: "17:00" + open-pull-requests-limit: 99 + ignore: + - dependency-name: Microsoft.EntityFrameworkCore.Design + versions: + - "> 2.2.6" + - dependency-name: Microsoft.EntityFrameworkCore.Sqlite + versions: + - "> 2.2.6" + - dependency-name: Microsoft.EntityFrameworkCore.Sqlite.Core + versions: + - "> 2.2.6" + - dependency-name: Microsoft.Extensions.DependencyInjection + versions: + - ">= 5.a, < 6" + - dependency-name: NUnit3TestAdapter + versions: + - ">= 3.16.a, < 3.17" + - dependency-name: Microsoft.NET.Test.Sdk + versions: + - 16.9.1 + - dependency-name: Microsoft.Extensions.DependencyInjection + versions: + - 3.1.11 + - 3.1.12 + - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson + versions: + - 3.1.11 + - dependency-name: Microsoft.NETCore.Targets + versions: + - 5.0.0 + - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.MessagePack + versions: + - 5.0.2 + - dependency-name: NUnit + versions: + - 3.13.1 + - dependency-name: Microsoft.AspNetCore.SignalR.Client + versions: + - 3.1.11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c327f01b3..e14be20642 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in * the in-game logs, which are located at: * `%AppData%/osu/logs` (on Windows), * `~/.local/share/osu/logs` (on Linux and macOS), - * `Android/Data/sh.ppy.osulazer/logs` (on Android), + * `Android/data/sh.ppy.osulazer/files/logs` (on Android), * on iOS they can be obtained by connecting your device to your desktop and [copying the `logs` directory from the app's own document storage using iTunes](https://support.apple.com/en-us/HT201301#copy-to-computer), * your system specifications (including the operating system and platform you are playing on), * a reproduction scenario (list of steps you have performed leading up to the occurrence of the bug), diff --git a/README.md b/README.md index 0d6af2aeba..eb790ca18f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Commo This project is under heavy development, but is in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. -**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passses come at the end of development, preceeded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. +**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project: diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index 98a32f9b3a..992f954a3a 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index afa7b03536..7571d1827a 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index c9f87a8551..1c8ed54440 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index afa7b03536..7571d1827a 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/osu.Android.props b/osu.Android.props index ed2b27e1c7..80b1c5b52f 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 0c21c75290..4a28ab3722 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -9,6 +9,7 @@ using System.Reflection; using System.Runtime.Versioning; using System.Threading.Tasks; using Microsoft.Win32; +using osu.Desktop.Security; using osu.Desktop.Overlays; using osu.Framework.Platform; using osu.Game; @@ -113,6 +114,8 @@ namespace osu.Desktop if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) LoadComponentAsync(new GameplayWinKeyBlocker(), Add); + + LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); } protected override void ScreenChanged(IScreen lastScreen, IScreen newScreen) diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs new file mode 100644 index 0000000000..01458b4c37 --- /dev/null +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs @@ -0,0 +1,83 @@ +// 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.Security.Principal; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; + +namespace osu.Desktop.Security +{ + /// + /// Checks if the game is running with elevated privileges (as admin in Windows, root in Unix) and displays a warning notification if so. + /// + public class ElevatedPrivilegesChecker : Component + { + [Resolved] + private NotificationOverlay notifications { get; set; } + + private bool elevated; + + [BackgroundDependencyLoader] + private void load() + { + elevated = checkElevated(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (elevated) + notifications.Post(new ElevatedPrivilegesNotification()); + } + + private bool checkElevated() + { + try + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + if (!OperatingSystem.IsWindows()) return false; + + var windowsIdentity = WindowsIdentity.GetCurrent(); + var windowsPrincipal = new WindowsPrincipal(windowsIdentity); + + return windowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator); + + case RuntimeInfo.Platform.macOS: + case RuntimeInfo.Platform.Linux: + return Mono.Unix.Native.Syscall.geteuid() == 0; + } + } + catch + { + } + + return false; + } + + private class ElevatedPrivilegesNotification : SimpleNotification + { + public override bool IsImportant => true; + + public ElevatedPrivilegesNotification() + { + Text = $"Running osu! as {(RuntimeInfo.IsUnix ? "root" : "administrator")} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user."; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, NotificationOverlay notificationOverlay) + { + Icon = FontAwesome.Solid.ShieldAlt; + IconBackgound.Colour = colours.YellowDark; + } + } + } +} diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 3e0f0cb7f6..ad5c323e9b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -25,6 +25,7 @@ + diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index ea43d9a54c..bfcf4ef35e 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -8,7 +8,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 48efd73222..517027a9fc 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; @@ -20,6 +21,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Game.Tests.Visual; +using osuTK; namespace osu.Game.Rulesets.Catch.Tests { @@ -170,16 +172,25 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestCatcherStacking() + public void TestCatcherRandomStacking() + { + AddStep("catch more fruits", () => attemptCatch(() => new Fruit + { + X = (RNG.NextSingle() - 0.5f) * Catcher.CalculateCatchWidth(Vector2.One) + }, 50)); + } + + [Test] + public void TestCatcherStackingSameCaughtPosition() { AddStep("catch fruit", () => attemptCatch(new Fruit())); checkPlate(1); - AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + AddStep("catch more fruits", () => attemptCatch(() => new Fruit(), 9)); checkPlate(10); AddAssert("caught objects are stacked", () => - catcher.CaughtObjects.All(obj => obj.Y <= 0) && - catcher.CaughtObjects.Any(obj => obj.Y == 0) && - catcher.CaughtObjects.Any(obj => obj.Y < -20)); + catcher.CaughtObjects.All(obj => obj.Y <= Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) && + catcher.CaughtObjects.Any(obj => obj.Y == Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) && + catcher.CaughtObjects.Any(obj => obj.Y < -25)); } [Test] @@ -189,11 +200,11 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); AddAssert("tiny droplet is exploded", () => catcher.CaughtObjects.Count() == 1 && droppedObjectContainer.Count == 1); AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); - AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + AddStep("catch more fruits", () => attemptCatch(() => new Fruit(), 9)); AddStep("explode", () => catcher.Explode()); AddAssert("fruits are exploded", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); - AddStep("catch fruits", () => attemptCatch(new Fruit(), 10)); + AddStep("catch fruits", () => attemptCatch(() => new Fruit(), 10)); AddStep("drop", () => catcher.Drop()); AddAssert("fruits are dropped", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); } @@ -222,10 +233,15 @@ namespace osu.Game.Rulesets.Catch.Tests private void checkHyperDash(bool state) => AddAssert($"catcher is {(state ? "" : "not ")}hyper dashing", () => catcher.HyperDashing == state); - private void attemptCatch(CatchHitObject hitObject, int count = 1) + private void attemptCatch(CatchHitObject hitObject) + { + attemptCatch(() => hitObject, 1); + } + + private void attemptCatch(Func hitObject, int count) { for (var i = 0; i < count; i++) - attemptCatch(hitObject, out _, out _); + attemptCatch(hitObject(), out _, out _); } private void attemptCatch(CatchHitObject hitObject, out DrawableCatchHitObject drawableObject, out JudgementResult result) diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 1cbfa6338e..ad404e1f63 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -8,6 +8,8 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; @@ -31,12 +33,32 @@ namespace osu.Game.Rulesets.Catch.Tests private float circleSize; + private ScheduledDelegate addManyFruit; + + private BeatmapDifficulty beatmapDifficulty; + public TestSceneCatcherArea() { AddSliderStep("circle size", 0, 8, 5, createCatcher); AddToggleStep("hyper dash", t => this.ChildrenOfType().ForEach(area => area.ToggleHyperDash(t))); - AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddStep("catch centered fruit", () => attemptCatch(new Fruit())); + AddStep("catch many random fruit", () => + { + int count = 50; + + addManyFruit?.Cancel(); + addManyFruit = Scheduler.AddDelayed(() => + { + attemptCatch(new Fruit + { + X = (RNG.NextSingle() - 0.5f) * Catcher.CalculateCatchWidth(beatmapDifficulty) * 0.6f, + }); + + if (count-- == 0) + addManyFruit?.Cancel(); + }, 50, true); + }); AddStep("catch fruit last in combo", () => attemptCatch(new Fruit { LastInCombo = true })); AddStep("catch kiai fruit", () => attemptCatch(new TestSceneCatcher.TestKiaiFruit())); AddStep("miss last in combo", () => attemptCatch(new Fruit { X = 100, LastInCombo = true })); @@ -45,10 +67,7 @@ namespace osu.Game.Rulesets.Catch.Tests private void attemptCatch(Fruit fruit) { fruit.X = fruit.OriginalX + catcher.X; - fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty - { - CircleSize = circleSize - }); + fruit.ApplyDefaults(new ControlPointInfo(), beatmapDifficulty); foreach (var area in this.ChildrenOfType()) { @@ -71,6 +90,11 @@ namespace osu.Game.Rulesets.Catch.Tests { circleSize = size; + beatmapDifficulty = new BeatmapDifficulty + { + CircleSize = circleSize + }; + SetContents(() => { var droppedObjectContainer = new Container @@ -84,7 +108,7 @@ namespace osu.Game.Rulesets.Catch.Tests Children = new Drawable[] { droppedObjectContainer, - new TestCatcherArea(droppedObjectContainer, new BeatmapDifficulty { CircleSize = size }) + new TestCatcherArea(droppedObjectContainer, beatmapDifficulty) { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, 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 c2d9a923d9..77e9d672e3 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 @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index f4ddbd3021..e3c457693e 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -114,6 +114,7 @@ namespace osu.Game.Rulesets.Catch return new Mod[] { new CatchModDifficultyAdjust(), + new CatchModClassic(), }; case ModType.Automation: @@ -126,7 +127,8 @@ namespace osu.Game.Rulesets.Catch case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()), + new CatchModFloatingFruits() }; default: diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs b/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs new file mode 100644 index 0000000000..9624e84018 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs @@ -0,0 +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.Mods; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModClassic : ModClassic + { + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs new file mode 100644 index 0000000000..63203dd57c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs @@ -0,0 +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 osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModFloatingFruits : Mod, IApplicableToDrawableRuleset + { + public override string Name => "Floating Fruits"; + public override string Acronym => "FF"; + public override string Description => "The fruits are... floating?"; + public override double ScoreMultiplier => 1; + public override IconUsage? Icon => FontAwesome.Solid.Cloud; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.Anchor = Anchor.Centre; + drawableRuleset.Origin = Anchor.Centre; + + drawableRuleset.Scale = new Vector2(1, -1); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 5d57e84b75..0d6a577d1e 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -53,6 +53,16 @@ namespace osu.Game.Rulesets.Catch.UI /// public const double BASE_SPEED = 1.0; + /// + /// The amount by which caught fruit should be offset from the plate surface to make them look visually "caught". + /// + public const float CAUGHT_FRUIT_VERTICAL_OFFSET = -5; + + /// + /// The amount by which caught fruit should be scaled down to fit on the plate. + /// + private const float caught_fruit_scale_adjust = 0.5f; + [NotNull] private readonly Container trailsTarget; @@ -202,13 +212,13 @@ namespace osu.Game.Rulesets.Catch.UI /// Calculates the width of the area used for attempting catches in gameplay. /// /// The scale of the catcher. - internal static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; + public static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The beatmap difficulty. - internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); + public static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); /// /// Determine if this catcher can catch a in the current position. @@ -240,7 +250,7 @@ namespace osu.Game.Rulesets.Catch.UI if (result.IsHit) { - var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X / 2); + var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X); if (CatchFruitOnPlate) placeCaughtObject(palpableObject, positionInStack); @@ -384,16 +394,7 @@ namespace osu.Game.Rulesets.Catch.UI { updateTrailVisibility(); - if (hyperDashing) - { - this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } - else - { - this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } + this.FadeColour(hyperDashing ? hyperDashColour : Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); } private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; @@ -479,7 +480,7 @@ namespace osu.Game.Rulesets.Catch.UI caughtObject.CopyStateFrom(drawableObject); caughtObject.Anchor = Anchor.TopCentre; caughtObject.Position = position; - caughtObject.Scale /= 2; + caughtObject.Scale *= caught_fruit_scale_adjust; caughtObjectContainer.Add(caughtObject); @@ -489,19 +490,21 @@ namespace osu.Game.Rulesets.Catch.UI private Vector2 computePositionInStack(Vector2 position, float displayRadius) { - const float radius_div_2 = CatchHitObject.OBJECT_RADIUS / 2; - const float allowance = 10; + // this is taken from osu-stable (lenience should be 10 * 10 at standard scale). + const float lenience_adjust = 10 / CatchHitObject.OBJECT_RADIUS; - while (caughtObjectContainer.Any(f => Vector2Extensions.Distance(f.Position, position) < (displayRadius + radius_div_2) / (allowance / 2))) + float adjustedRadius = displayRadius * lenience_adjust; + float checkDistance = MathF.Pow(adjustedRadius, 2); + + // offset fruit vertically to better place "above" the plate. + position.Y += CAUGHT_FRUIT_VERTICAL_OFFSET; + + while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance)) { - float diff = (displayRadius + radius_div_2) / allowance; - - position.X += (RNG.NextSingle() - 0.5f) * diff * 2; - position.Y -= RNG.NextSingle() * diff; + position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius); + position.Y -= RNG.NextSingle(0, 5); } - position.X = Math.Clamp(position.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); - return position; } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 42ea12214f..668487f673 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -324,6 +324,33 @@ namespace osu.Game.Rulesets.Mania.Tests assertTailJudgement(HitResult.Ok); } + [Test] + public void TestZeroLength() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = 1000, + Duration = 0, + Column = 0, + }, + }, + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + }; + + performTest(new List + { + new ManiaReplayFrame(beatmap.HitObjects[0].StartTime, ManiaAction.Key1), + new ManiaReplayFrame(beatmap.HitObjects[0].GetEndTime() + 1), + }, beatmap); + + AddAssert("hold note hit", () => judgementResults.Where(j => beatmap.HitObjects[0].NestedHitObjects.Contains(j.HitObject)) + .All(j => j.Type.IsHit())); + } + private void assertHeadJudgement(HitResult result) => AddAssert($"head judged as {result}", () => judgementResults.First(j => j.HitObject is Note).Type == result); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs new file mode 100644 index 0000000000..e14ad92842 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.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.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Tests.Visual; +using osu.Framework.Timing; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Configuration; +using osu.Framework.Bindables; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class TestSceneTimingBasedNoteColouring : OsuTestScene + { + [Resolved] + private RulesetConfigCache configCache { get; set; } + + private readonly Bindable configTimingBasedNoteColouring = new Bindable(); + + protected override void LoadComplete() + { + const double beat_length = 500; + + var ruleset = new ManiaRuleset(); + + var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }) + { + HitObjects = + { + new Note { StartTime = 0 }, + new Note { StartTime = beat_length / 16 }, + new Note { StartTime = beat_length / 12 }, + new Note { StartTime = beat_length / 8 }, + new Note { StartTime = beat_length / 6 }, + new Note { StartTime = beat_length / 4 }, + new Note { StartTime = beat_length / 3 }, + new Note { StartTime = beat_length / 2 }, + new Note { StartTime = beat_length } + }, + ControlPointInfo = new ControlPointInfo(), + BeatmapInfo = { Ruleset = ruleset.RulesetInfo }, + }; + + foreach (var note in beatmap.HitObjects) + { + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + } + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint + { + BeatLength = beat_length + }); + + Child = new Container + { + Clock = new FramedClock(new ManualClock()), + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + ruleset.CreateDrawableRulesetWith(beatmap) + } + }; + + var config = (ManiaRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + config.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); + + AddStep("Enable", () => configTimingBasedNoteColouring.Value = true); + AddStep("Disable", () => configTimingBasedNoteColouring.Value = false); + } + } +} 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 64e934efd2..8f8b99b092 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 @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 39d0f4bae4..ac8168dfc9 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -22,6 +22,7 @@ namespace osu.Game.Rulesets.Mania.Configuration SetDefault(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); + SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings @@ -34,6 +35,7 @@ namespace osu.Game.Rulesets.Mania.Configuration public enum ManiaRulesetSetting { ScrollTime, - ScrollDirection + ScrollDirection, + TimingBasedNoteColouring } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index a13afdfffe..093a8da24f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(true); + EndPlacement(HitObject.Duration > 0); } private double originalStartTime; diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs similarity index 78% rename from osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs rename to osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index 445df79f6f..b0af8c503b 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -12,16 +12,16 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Mania.Edit { - public class DrawableManiaEditRuleset : DrawableManiaRuleset + public class DrawableManiaEditorRuleset : DrawableManiaRuleset { public new IScrollingInfo ScrollingInfo => base.ScrollingInfo; - public DrawableManiaEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) + public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) : base(ruleset, beatmap, mods) { } - protected override Playfield CreatePlayfield() => new ManiaEditPlayfield(Beatmap.Stages) + protected override Playfield CreatePlayfield() => new ManiaEditorPlayfield(Beatmap.Stages) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index 2fa3f378ff..c4429176d1 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -4,6 +4,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Screens.Edit.Compose.Components; @@ -30,6 +31,6 @@ namespace osu.Game.Rulesets.Mania.Edit return base.CreateBlueprintFor(hitObject); } - protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaEditPlayfield.cs b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs similarity index 75% rename from osu.Game.Rulesets.Mania/Edit/ManiaEditPlayfield.cs rename to osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs index a42f793a77..186d50716e 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaEditPlayfield.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs @@ -7,9 +7,9 @@ using System.Collections.Generic; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaEditPlayfield : ManiaPlayfield + public class ManiaEditorPlayfield : ManiaPlayfield { - public ManiaEditPlayfield(List stages) + public ManiaEditorPlayfield(List stages) : base(stages) { } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index d9570bf8be..2baec95c94 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Edit { public class ManiaHitObjectComposer : HitObjectComposer { - private DrawableManiaEditRuleset drawableRuleset; + private DrawableManiaEditorRuleset drawableRuleset; private ManiaBeatSnapGrid beatSnapGrid; private InputManager inputManager; @@ -80,7 +80,7 @@ namespace osu.Game.Rulesets.Mania.Edit protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) { - drawableRuleset = new DrawableManiaEditRuleset(ruleset, beatmap, mods); + drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods); // This is the earliest we can cache the scrolling info to ourselves, before masks are added to the hierarchy and inject it dependencies.CacheAs(drawableRuleset.ScrollingInfo); diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 2689ed4112..7042110423 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -7,12 +7,13 @@ using osu.Framework.Allocation; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaSelectionHandler : SelectionHandler + public class ManiaSelectionHandler : EditorSelectionHandler { [Resolved] private IScrollingInfo scrollingInfo { get; set; } @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Edit [Resolved] private HitObjectComposer composer { get; set; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; @@ -30,11 +31,11 @@ namespace osu.Game.Rulesets.Mania.Edit return true; } - private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) + private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) { var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield; - var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.ScreenSpacePosition); + var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.Blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta); if (currentColumn == null) return; diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 88b63606b9..b3889bc7d3 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -239,6 +239,7 @@ namespace osu.Game.Rulesets.Mania new ManiaModDualStages(), new ManiaModMirror(), new ManiaModDifficultyAdjust(), + new ManiaModClassic(), new ManiaModInvert(), new ManiaModConstantSpeed() }; diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index de77af8306..1c89d9cd00 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -37,6 +37,11 @@ namespace osu.Game.Rulesets.Mania Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), KeyboardStep = 5 }, + new SettingsCheckbox + { + LabelText = "Timing-based note colouring", + Current = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring), + } }; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs new file mode 100644 index 0000000000..073dda9de8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs @@ -0,0 +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.Mods; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModClassic : ModClassic + { + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 828ee7b03e..02829d87bd 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -221,7 +221,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // As the note is being held, adjust the size of the sizing container. This has two effects: // 1. The contained masking container will mask the body and ticks. // 2. The head note will move along with the new "head position" in the container. - if (Head.IsHit && releaseTime == null) + if (Head.IsHit && releaseTime == null && DrawHeight > 0) { // How far past the hit target this hold note is. Always a positive value. float yOffset = Math.Max(0, Direction.Value == ScrollingDirection.Up ? -Y : Y); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index b512986ccb..36565e14aa 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -2,13 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -17,6 +23,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableNote : DrawableManiaHitObject, IKeyBindingHandler { + [Resolved] + private OsuColour colours { get; set; } + + [Resolved(canBeNull: true)] + private IBeatmap beatmap { get; set; } + + private readonly Bindable configTimingBasedNoteColouring = new Bindable(); + protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note; private readonly Drawable headPiece; @@ -34,6 +48,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }); } + [BackgroundDependencyLoader(true)] + private void load(ManiaRulesetConfigManager rulesetConfig) + { + rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); + } + + protected override void LoadComplete() + { + HitObject.StartTimeBindable.BindValueChanged(_ => updateSnapColour()); + configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour(), true); + } + protected override void OnDirectionChanged(ValueChangedEvent e) { base.OnDirectionChanged(e); @@ -73,5 +99,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public virtual void OnReleased(ManiaAction action) { } + + private void updateSnapColour() + { + if (beatmap == null) return; + + int snapDivisor = beatmap.ControlPointInfo.GetClosestBeatDivisor(HitObject.StartTime); + + Colour = configTimingBasedNoteColouring.Value ? BindableBeatDivisor.GetColourFor(snapDivisor, colours) : Color4.White; + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 27bf50493d..6289744df1 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Game.Rulesets.Mania.Objects.Types; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs index f9445a9a96..6139b0e676 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Checks; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Tests.Beatmaps; using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks @@ -30,25 +31,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks [Test] public void TestCircleInCenter() { - var beatmap = new Beatmap + assertOk(new Beatmap { HitObjects = new List { new HitCircle { StartTime = 3000, - Position = playfield_centre // Playfield is 640 x 480. + Position = playfield_centre } } - }; - - Assert.That(check.Run(beatmap), Is.Empty); + }); } [Test] public void TestCircleNearEdge() { - var beatmap = new Beatmap + assertOk(new Beatmap { HitObjects = new List { @@ -58,15 +57,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks Position = new Vector2(5, 5) } } - }; - - Assert.That(check.Run(beatmap), Is.Empty); + }); } [Test] public void TestCircleNearEdgeStackedOffscreen() { - var beatmap = new Beatmap + assertOffscreenCircle(new Beatmap { HitObjects = new List { @@ -77,15 +74,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks StackHeight = 5 } } - }; - - assertOffscreenCircle(beatmap); + }); } [Test] public void TestCircleOffscreen() { - var beatmap = new Beatmap + assertOffscreenCircle(new Beatmap { HitObjects = new List { @@ -95,15 +90,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks Position = new Vector2(0, 0) } } - }; - - assertOffscreenCircle(beatmap); + }); } [Test] public void TestSliderInCenter() { - var beatmap = new Beatmap + assertOk(new Beatmap { HitObjects = new List { @@ -118,15 +111,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks }), } } - }; - - Assert.That(check.Run(beatmap), Is.Empty); + }); } [Test] public void TestSliderNearEdge() { - var beatmap = new Beatmap + assertOk(new Beatmap { HitObjects = new List { @@ -141,15 +132,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks }), } } - }; - - Assert.That(check.Run(beatmap), Is.Empty); + }); } [Test] public void TestSliderNearEdgeStackedOffscreen() { - var beatmap = new Beatmap + assertOffscreenSlider(new Beatmap { HitObjects = new List { @@ -165,15 +154,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks StackHeight = 5 } } - }; - - assertOffscreenSlider(beatmap); + }); } [Test] public void TestSliderOffscreenStart() { - var beatmap = new Beatmap + assertOffscreenSlider(new Beatmap { HitObjects = new List { @@ -188,15 +175,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks }), } } - }; - - assertOffscreenSlider(beatmap); + }); } [Test] public void TestSliderOffscreenEnd() { - var beatmap = new Beatmap + assertOffscreenSlider(new Beatmap { HitObjects = new List { @@ -211,15 +196,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks }), } } - }; - - assertOffscreenSlider(beatmap); + }); } [Test] public void TestSliderOffscreenPath() { - var beatmap = new Beatmap + assertOffscreenSlider(new Beatmap { HitObjects = new List { @@ -236,14 +219,17 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks }), } } - }; + }); + } - assertOffscreenSlider(beatmap); + private void assertOk(IBeatmap beatmap) + { + Assert.That(check.Run(beatmap, new TestWorkingBeatmap(beatmap)), Is.Empty); } private void assertOffscreenCircle(IBeatmap beatmap) { - var issues = check.Run(beatmap).ToList(); + var issues = check.Run(beatmap, new TestWorkingBeatmap(beatmap)).ToList(); Assert.That(issues, Has.Count.EqualTo(1)); Assert.That(issues.Single().Template is CheckOffscreenObjects.IssueTemplateOffscreenCircle); @@ -251,7 +237,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks private void assertOffscreenSlider(IBeatmap beatmap) { - var issues = check.Run(beatmap).ToList(); + var issues = check.Run(beatmap, new TestWorkingBeatmap(beatmap)).ToList(); Assert.That(issues, Has.Count.EqualTo(1)); Assert.That(issues.Single().Template is CheckOffscreenObjects.IssueTemplateOffscreenSlider); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs index 5fc1082743..8b3fead366 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests { Position = new Vector2(128, 128), ComboIndex = 1, - }), null)); + }))); } private HitCircle prepareObject(HitCircle circle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index aac6db60fe..e698766aac 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Tests new Vector2(300, 0), }), RepeatCount = 1 - }), null)); + }))); } [Test] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 2cc031405e..590d159300 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -34,6 +34,18 @@ namespace osu.Game.Rulesets.Osu.Tests private List judgementResults; + [Test] + public void TestPressBothKeysSimultaneouslyAndReleaseOne() + { + performTest(new List + { + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, + }); + + AddAssert("Tracking retained", assertMaxJudge); + } + /// /// Scenario: /// - Press a key before a slider starts diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs index d7fbc7ac48..8c97c02049 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Tests Position = new Vector2(256, 192), ComboIndex = 1, Duration = 1000, - }), null)); + }))); AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 14c709cae1..8ff21057b5 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osu.Game.Storyboards; using osu.Game.Tests.Visual; using osuTK; @@ -193,7 +194,7 @@ namespace osu.Game.Rulesets.Osu.Tests addSeekStep(0); - AddStep("adjust track rate", () => Player.GameplayClockContainer.UserPlaybackRate.Value = rate); + AddStep("adjust track rate", () => ((MasterGameplayClockContainer)Player.GameplayClockContainer).UserPlaybackRate.Value = rate); addSeekStep(1000); AddAssert("progress almost same", () => Precision.AlmostEquals(expectedProgress, drawableSpinner.Progress, 0.05)); 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 f743d65db3..e01e858873 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 @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs index 8dd550bb96..299f8fc43a 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints public abstract class OsuSelectionBlueprint : OverlaySelectionBlueprint where T : OsuHitObject { - protected new T HitObject => (T)DrawableObject.HitObject; + protected T HitObject => (T)DrawableObject.HitObject; protected override bool AlwaysShowWhenSelected => true; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 77ea3b05dc..8b20df9a68 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -207,7 +207,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last); lastPoint = last; - return lastPiece?.IsHovered != true; + return lastPiece.IsHovered != true; } private void updateSlider() diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs index 27cae2ecc1..4b0a7531a1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs @@ -31,9 +31,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks new IssueTemplateOffscreenSlider(this) }; - public IEnumerable Run(IBeatmap beatmap) + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap) { - foreach (var hitobject in beatmap.HitObjects) + foreach (var hitobject in playableBeatmap.HitObjects) { switch (hitobject) { diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs deleted file mode 100644 index 5fdb79cbbd..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs +++ /dev/null @@ -1,79 +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 System.Linq; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Rulesets.Osu.UI; -using osu.Game.Rulesets.UI; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Edit -{ - public class DrawableOsuEditRuleset : DrawableOsuRuleset - { - public DrawableOsuEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) - : base(ruleset, beatmap, mods) - { - } - - protected override Playfield CreatePlayfield() => new OsuEditPlayfield(); - - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One }; - - private class OsuEditPlayfield : OsuPlayfield - { - protected override GameplayCursorContainer CreateCursor() => null; - - protected override void OnNewDrawableHitObject(DrawableHitObject d) - { - d.ApplyCustomUpdateState += updateState; - } - - /// - /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. - /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. - /// - private const double editor_hit_object_fade_out_extension = 700; - - private void updateState(DrawableHitObject hitObject, ArmedState state) - { - if (state == ArmedState.Idle) - return; - - // adjust the visuals of certain object types to make them stay on screen for longer than usual. - switch (hitObject) - { - default: - // there are quite a few drawable hit types we don't want to extend (spinners, ticks etc.) - return; - - case DrawableSlider _: - // no specifics to sliders but let them fade slower below. - break; - - case DrawableHitCircle circle: // also handles slider heads - circle.ApproachCircle - .FadeOutFromOne(editor_hit_object_fade_out_extension) - .Expire(); - break; - } - - // Get the existing fade out transform - var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha)); - - if (existing == null) - return; - - hitObject.RemoveTransform(existing); - - using (hitObject.BeginAbsoluteSequence(existing.StartTime)) - hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs new file mode 100644 index 0000000000..aeeae84d14 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs @@ -0,0 +1,102 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class DrawableOsuEditorRuleset : DrawableOsuRuleset + { + public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) + : base(ruleset, beatmap, mods) + { + } + + protected override Playfield CreatePlayfield() => new OsuEditorPlayfield(); + + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One }; + + private class OsuEditorPlayfield : OsuPlayfield + { + private Bindable hitAnimations; + + protected override GameplayCursorContainer CreateCursor() => null; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + hitAnimations = config.GetBindable(OsuSetting.EditorHitAnimations); + } + + protected override void OnNewDrawableHitObject(DrawableHitObject d) + { + d.ApplyCustomUpdateState += updateState; + } + + /// + /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. + /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. + /// + private const double editor_hit_object_fade_out_extension = 700; + + private void updateState(DrawableHitObject hitObject, ArmedState state) + { + if (state == ArmedState.Idle || hitAnimations.Value) + return; + + if (hitObject is DrawableHitCircle circle) + { + circle.ApproachCircle + .FadeOutFromOne(editor_hit_object_fade_out_extension * 4) + .Expire(); + + circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint); + } + + if (hitObject is IHasMainCirclePiece mainPieceContainer) + { + // clear any explode animation logic. + mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.HitStateUpdateTime, true); + mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.HitStateUpdateTime, true); + } + + if (hitObject is DrawableSliderRepeat repeat) + { + repeat.Arrow.ApplyTransformsAt(hitObject.HitStateUpdateTime, true); + repeat.Arrow.ClearTransformsAfter(hitObject.HitStateUpdateTime, true); + } + + // adjust the visuals of top-level object types to make them stay on screen for longer than usual. + switch (hitObject) + { + case DrawableSlider _: + case DrawableHitCircle _: + // Get the existing fade out transform + var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha)); + + if (existing == null) + return; + + hitObject.RemoveTransform(existing); + + using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime)) + hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index 1c7ab00bbb..dab6483179 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Osu.Edit new CheckOffscreenObjects() }; - public IEnumerable Run(IBeatmap beatmap) => checks.SelectMany(check => check.Run(beatmap)); + public IEnumerable Run(IBeatmap playableBeatmap, WorkingBeatmap workingBeatmap) + { + return checks.SelectMany(check => check.Run(playableBeatmap, workingBeatmap)); + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs index a68ed34e6b..abac5eb56e 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Edit { } - protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) { diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 396fd41377..806b7e6051 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit } protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) - => new DrawableOsuEditRuleset(ruleset, beatmap, mods); + => new DrawableOsuEditorRuleset(ruleset, beatmap, mods); protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { @@ -147,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Edit if (b.IsSelected) continue; - var hitObject = (OsuHitObject)b.HitObject; + var hitObject = (OsuHitObject)b.Item; Vector2? snap = checkSnap(hitObject.Position); if (snap == null && hitObject.Position != hitObject.EndPosition) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index de0a4682a3..c2c1f6d602 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; +using osu.Game.Extensions; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; @@ -15,7 +16,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuSelectionHandler : SelectionHandler + public class OsuSelectionHandler : EditorSelectionHandler { protected override void OnSelectionChanged() { @@ -36,13 +37,13 @@ namespace osu.Game.Rulesets.Osu.Edit referencePathTypes = null; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { var hitObjects = selectedMovableObjects; // this will potentially move the selection out of bounds... foreach (var h in hitObjects) - h.Position += moveEvent.InstantDelta; + h.Position += this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); // but this will be corrected. moveSelectionInBounds(); @@ -374,8 +375,7 @@ namespace osu.Game.Rulesets.Osu.Edit /// /// All osu! hitobjects which can be moved/rotated/scaled. /// - private OsuHitObject[] selectedMovableObjects => EditorBeatmap.SelectedHitObjects - .OfType() + private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType() .Where(h => !(h is Spinner)) .ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs index aee431284e..9ae9653e9b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs @@ -1,47 +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 osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Game.Configuration; +using System.Collections.Generic; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.UI; -using osu.Game.Rulesets.UI; -using osuTK; +using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModBarrelRoll : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + public class OsuModBarrelRoll : ModBarrelRoll, IApplicableToDrawableHitObjects { - [SettingSource("Roll speed", "Rotations per minute")] - public BindableNumber SpinSpeed { get; } = new BindableDouble(0.5) + public void ApplyToDrawableHitObjects(IEnumerable drawables) { - MinValue = 0.02, - MaxValue = 4, - Precision = 0.01, - }; - - [SettingSource("Direction", "The direction of rotation")] - public Bindable Direction { get; } = new Bindable(RotationDirection.Clockwise); - - public override string Name => "Barrel Roll"; - public override string Acronym => "BR"; - public override string Description => "The whole playfield is on a wheel!"; - public override double ScoreMultiplier => 1; - - public override string SettingDescription => $"{SpinSpeed.Value} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; - - public void Update(Playfield playfield) - { - playfield.Rotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); - } - - public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - // scale the playfield to allow all hitobjects to stay within the visible region. - drawableRuleset.Playfield.Scale = new Vector2(OsuPlayfield.BASE_SIZE.Y / OsuPlayfield.BASE_SIZE.X); + foreach (var d in drawables) + { + d.OnUpdate += _ => + { + switch (d) + { + case DrawableHitCircle circle: + circle.CirclePiece.Rotation = -CurrentRotation; + break; + } + }; + } } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 882f848190..77dea5b0dc 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -16,22 +15,8 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModClassic : Mod, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset + public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset { - public override string Name => "Classic"; - - public override string Acronym => "CL"; - - public override double ScoreMultiplier => 1; - - public override IconUsage? Icon => FontAwesome.Solid.History; - - public override string Description => "Feeling nostalgic?"; - - public override bool Ranked => false; - - public override ModType Type => ModType.Conversion; - [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 9c5e41f245..a01cec4bb3 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -34,9 +34,9 @@ namespace osu.Game.Rulesets.Osu.Mods var osuObject = (OsuHitObject)drawable.HitObject; Vector2 origin = drawable.Position; - // Wiggle the repeat points with the slider instead of independently. + // Wiggle the repeat points and the tail with the slider instead of independently. // Also fixes an issue with repeat points being positioned incorrectly. - if (osuObject is SliderRepeat) + if (osuObject is SliderRepeat || osuObject is SliderTailCircle) return; Random objRand = new Random((int)osuObject.StartTime); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 5541d0e790..cda4715280 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections Entry = null; } - private void onEntryInvalidated() => refreshPoints(); + private void onEntryInvalidated() => Scheduler.AddOnce(refreshPoints); private void refreshPoints() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 189003875d..1bf9e76d7d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -19,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableHitCircle : DrawableOsuHitObject + public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece { public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; @@ -66,7 +66,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return true; }, }, - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()), + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, ApproachCircle = new ApproachCircle { Alpha = 0, @@ -178,6 +182,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // todo: temporary / arbitrary, used for lifetime optimisation. this.Delay(800).FadeOut(); + (CirclePiece.Drawable as IMainCirclePiece)?.Animate(state); + switch (state) { case ArmedState.Idle: diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 76490e0de1..7b4188edab 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking + public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, IHasMainCirclePiece { public new SliderRepeat HitObject => (SliderRepeat)base.HitObject; @@ -26,9 +26,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private double animDuration; - public Drawable CirclePiece { get; private set; } + public SkinnableDrawable CirclePiece { get; private set; } + + public ReverseArrowPiece Arrow { get; private set; } + private Drawable scaleContainer; - private ReverseArrowPiece arrow; public override bool DisplayResult => false; @@ -53,11 +55,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Children = new[] + Children = new Drawable[] { // no default for this; only visible in legacy skins. - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()), - arrow = new ReverseArrowPiece(), + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + Arrow = new ReverseArrowPiece(), } }; @@ -91,6 +97,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateHitStateTransforms(state); + (CirclePiece.Drawable as IMainCirclePiece)?.Animate(state); + switch (state) { case ArmedState.Idle: @@ -102,8 +110,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables break; case ArmedState.Hit: - this.FadeOut(animDuration, Easing.Out) - .ScaleTo(Scale * 1.5f, animDuration, Easing.Out); + this.FadeOut(animDuration, Easing.Out); + + const float final_scale = 1.5f; + + Arrow.ScaleTo(Scale * final_scale, animDuration, Easing.Out); + CirclePiece.ScaleTo(Scale * final_scale, animDuration, Easing.Out); break; } } @@ -139,18 +151,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } float aimRotation = MathUtils.RadiansToDegrees(MathF.Atan2(aimRotationVector.Y - Position.Y, aimRotationVector.X - Position.X)); - while (Math.Abs(aimRotation - arrow.Rotation) > 180) - aimRotation += aimRotation < arrow.Rotation ? 360 : -360; + while (Math.Abs(aimRotation - Arrow.Rotation) > 180) + aimRotation += aimRotation < Arrow.Rotation ? 360 : -360; if (!hasRotation) { - arrow.Rotation = aimRotation; + Arrow.Rotation = aimRotation; hasRotation = true; } else { // If we're already snaking, interpolate to smooth out sharp curves (linear sliders, mainly). - arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint); + Arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), Arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 87f098dd29..d81af053d1 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -7,12 +7,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking + public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking, IHasMainCirclePiece { public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject; @@ -34,7 +35,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public bool Tracking { get; set; } - private SkinnableDrawable circlePiece; + public SkinnableDrawable CirclePiece { get; private set; } + private Container scaleContainer; public DrawableSliderTail() @@ -63,7 +65,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Children = new Drawable[] { // no default for this; only visible in legacy skins. - circlePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) } }, }; @@ -75,7 +77,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateInitialTransforms(); - circlePiece.FadeInFromZero(HitObject.TimeFadeIn); + CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); } protected override void UpdateHitStateTransforms(ArmedState state) @@ -84,6 +86,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Debug.Assert(HitObject.HitWindows != null); + (CirclePiece.Drawable as IMainCirclePiece)?.Animate(state); + switch (state) { case ArmedState.Idle: diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs new file mode 100644 index 0000000000..8bb7629542 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public interface IHasMainCirclePiece + { + SkinnableDrawable CirclePiece { get; } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/IMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/IMainCirclePiece.cs new file mode 100644 index 0000000000..17a1e29094 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/IMainCirclePiece.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public interface IMainCirclePiece + { + /// + /// Begins animating this . + /// + /// The of the related . + void Animate(ArmedState state); + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs index 46aeadc59b..b46baa00ba 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs @@ -13,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class MainCirclePiece : CompositeDrawable + public class MainCirclePiece : CompositeDrawable, IMainCirclePiece { private readonly CirclePiece circle; private readonly RingPiece ring; @@ -67,12 +67,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default }, true); indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); - - drawableObject.ApplyCustomUpdateState += updateState; - updateState(drawableObject, drawableObject.State.Value); } - private void updateState(DrawableHitObject drawableObject, ArmedState state) + public void Animate(ArmedState state) { using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) glow.FadeOut(400); diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs index 82b677e12c..8feeca56e8 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.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; @@ -134,6 +135,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// private double? timeToAcceptAnyKeyAfter; + /// + /// The actions that were pressed in the previous frame. + /// + private readonly List lastPressedActions = new List(); + protected override void Update() { base.Update(); @@ -152,8 +158,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { var otherKey = headCircleHitAction == OsuAction.RightButton ? OsuAction.LeftButton : OsuAction.RightButton; - // we can return to accepting all keys if the initial head circle key is the *only* key pressed, or all keys have been released. - if (actions?.Contains(otherKey) != true) + // we can start accepting any key once all other keys have been released in the previous frame. + if (!lastPressedActions.Contains(otherKey)) timeToAcceptAnyKeyAfter = Time.Current; } @@ -164,6 +170,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default lastScreenSpaceMousePosition.HasValue && followCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && // valid action (actions?.Any(isValidTrackingAction) ?? false); + + lastPressedActions.Clear(); + if (actions != null) + lastPressedActions.AddRange(actions); } /// diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 545e80a709..cf62165929 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -19,7 +20,7 @@ using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyMainCirclePiece : CompositeDrawable + public class LegacyMainCirclePiece : CompositeDrawable, IMainCirclePiece { private readonly string priorityLookup; private readonly bool hasNumber; @@ -138,12 +139,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy accentColour.BindValueChanged(colour => hitCircleSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); if (hasNumber) indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); - - drawableObject.ApplyCustomUpdateState += updateState; - updateState(drawableObject, drawableObject.State.Value); } - private void updateState(DrawableHitObject drawableObject, ArmedState state) + public void Animate(ArmedState state) { const double legacy_fade_duration = 240; diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index 44ca5e850f..27d48d1296 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.UI base.PopIn(); GameplayCursor.ActiveCursor.Hide(); - cursorScaleContainer.MoveTo(GameplayCursor.ActiveCursor.Position); + cursorScaleContainer.Position = ToLocalSpace(GameplayCursor.ActiveCursor.ScreenSpaceDrawQuad.Centre); clickToResumeCursor.Appear(); if (localCursorContainer == null) diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs index a970965141..f33c738b04 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { StartTime = 400, Major = true - }), null)); + }))); AddHitObject(barLine); RemoveHitObject(barLine); @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { StartTime = 200, Major = false - }), null)); + }))); AddHitObject(barLine); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs index 54450e27db..c389a05566 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Duration = 500, IsStrong = false, TickRate = 2 - }), null)); + }))); AddHitObject(drumRoll); RemoveHitObject(drumRoll); @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Duration = 400, IsStrong = true, TickRate = 16 - }), null)); + }))); AddHitObject(drumRoll); } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs index 52fd440857..c2f251fcb6 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Type = HitType.Rim, IsStrong = false, StartTime = 300 - }), null)); + }))); AddHitObject(hit); RemoveHitObject(hit); @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Type = HitType.Centre, IsStrong = true, StartTime = 500 - }), null)); + }))); AddHitObject(hit); } 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 eab144592f..2dfa1dfbb7 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 @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs index 8b41448c9d..b1b08a9461 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Edit.Blueprints; using osu.Game.Screens.Edit.Compose.Components; @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Taiko.Edit { } - protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler(); public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => new TaikoSelectionBlueprint(hitObject); diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index ac2dd4bdb6..48ee0d4cf4 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -8,12 +8,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Taiko.Edit { - public class TaikoSelectionHandler : SelectionHandler + public class TaikoSelectionHandler : EditorSelectionHandler { private readonly Bindable selectionRimState = new Bindable(); private readonly Bindable selectionStrongState = new Bindable(); @@ -72,16 +73,19 @@ namespace osu.Game.Rulesets.Taiko.Edit }); } - protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { - if (selection.All(s => s.HitObject is Hit)) + if (selection.All(s => s.Item is Hit)) yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } }; - if (selection.All(s => s.HitObject is TaikoHitObject)) + if (selection.All(s => s.Item is TaikoHitObject)) yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + + foreach (var item in base.GetContextMenuItemsForSelection(selection)) + yield return item; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; protected override void UpdateTernaryStates() { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs new file mode 100644 index 0000000000..5a4d18be98 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -0,0 +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.Mods; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModClassic : ModClassic + { + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 56f58f404b..f4e158ec32 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -135,6 +135,7 @@ namespace osu.Game.Rulesets.Taiko { new TaikoModRandom(), new TaikoModDifficultyAdjust(), + new TaikoModClassic(), }; case ModType.Automation: diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 920cc36776..a18f82fe4a 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -168,6 +168,8 @@ namespace osu.Game.Tests.Beatmaps.Formats protected override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs new file mode 100644 index 0000000000..7658ca728d --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.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 Moq; +using NUnit.Framework; +using osu.Framework.Audio.Track; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckAudioQualityTest + { + private CheckAudioQuality check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckAudioQuality(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { AudioFile = "abc123.jpg" } + } + }; + } + + [Test] + public void TestMissing() + { + // While this is a problem, it is out of scope for this check and is caught by a different one. + beatmap.Metadata.AudioFile = null; + + var mock = new Mock(); + mock.SetupGet(w => w.Beatmap).Returns(beatmap); + mock.SetupGet(w => w.Track).Returns((Track)null); + + Assert.That(check.Run(beatmap, mock.Object), Is.Empty); + } + + [Test] + public void TestAcceptable() + { + var mock = getMockWorkingBeatmap(192); + + Assert.That(check.Run(beatmap, mock.Object), Is.Empty); + } + + [Test] + public void TestNullBitrate() + { + var mock = getMockWorkingBeatmap(null); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateNoBitrate); + } + + [Test] + public void TestZeroBitrate() + { + var mock = getMockWorkingBeatmap(0); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateNoBitrate); + } + + [Test] + public void TestTooHighBitrate() + { + var mock = getMockWorkingBeatmap(320); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooHighBitrate); + } + + [Test] + public void TestTooLowBitrate() + { + var mock = getMockWorkingBeatmap(64); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooLowBitrate); + } + + /// + /// Returns the mock of the working beatmap with the given audio properties. + /// + /// The bitrate of the audio file the beatmap uses. + private Mock getMockWorkingBeatmap(int? audioBitrate) + { + var mockTrack = new Mock(); + mockTrack.SetupGet(t => t.Bitrate).Returns(audioBitrate); + + var mockWorkingBeatmap = new Mock(); + mockWorkingBeatmap.SetupGet(w => w.Beatmap).Returns(beatmap); + mockWorkingBeatmap.SetupGet(w => w.Track).Returns(mockTrack.Object); + + return mockWorkingBeatmap; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs new file mode 100644 index 0000000000..f0f972d2fa --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs @@ -0,0 +1,130 @@ +// 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.IO; +using System.Linq; +using JetBrains.Annotations; +using Moq; +using NUnit.Framework; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckBackgroundQualityTest + { + private CheckBackgroundQuality check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckBackgroundQuality(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { BackgroundFile = "abc123.jpg" }, + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo + { + Hash = "abcdef" + } + } + }) + } + } + }; + } + + [Test] + public void TestMissing() + { + // While this is a problem, it is out of scope for this check and is caught by a different one. + beatmap.Metadata.BackgroundFile = null; + var mock = getMockWorkingBeatmap(null, System.Array.Empty()); + + Assert.That(check.Run(beatmap, mock.Object), Is.Empty); + } + + [Test] + public void TestAcceptable() + { + var mock = getMockWorkingBeatmap(new Texture(1920, 1080)); + + Assert.That(check.Run(beatmap, mock.Object), Is.Empty); + } + + [Test] + public void TestTooHighResolution() + { + var mock = getMockWorkingBeatmap(new Texture(3840, 2160)); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateTooHighResolution); + } + + [Test] + public void TestLowResolution() + { + var mock = getMockWorkingBeatmap(new Texture(640, 480)); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateLowResolution); + } + + [Test] + public void TestTooLowResolution() + { + var mock = getMockWorkingBeatmap(new Texture(100, 100)); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateTooLowResolution); + } + + [Test] + public void TestTooUncompressed() + { + var mock = getMockWorkingBeatmap(new Texture(1920, 1080), new byte[1024 * 1024 * 3]); + + var issues = check.Run(beatmap, mock.Object).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateTooUncompressed); + } + + /// + /// Returns the mock of the working beatmap with the given background and filesize. + /// + /// The texture of the background. + /// The bytes that represent the background file. + private Mock getMockWorkingBeatmap(Texture background, [CanBeNull] byte[] fileBytes = null) + { + var stream = new MemoryStream(fileBytes ?? new byte[1024 * 1024]); + + var mock = new Mock(); + mock.SetupGet(w => w.Beatmap).Returns(beatmap); + mock.SetupGet(w => w.Background).Returns(background); + mock.Setup(w => w.GetStream(It.IsAny())).Returns(stream); + + return mock; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs new file mode 100644 index 0000000000..ffe5d34e67 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs @@ -0,0 +1,194 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckConcurrentObjectsTest + { + private CheckConcurrentObjects check; + + [SetUp] + public void Setup() + { + check = new CheckConcurrentObjects(); + } + + [Test] + public void TestCirclesSeparate() + { + assertOk(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 150 } + }); + } + + [Test] + public void TestCirclesConcurrent() + { + assertConcurrentSame(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 100 } + }); + } + + [Test] + public void TestCirclesAlmostConcurrent() + { + assertConcurrentSame(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 101 } + }); + } + + [Test] + public void TestSlidersSeparate() + { + assertOk(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 500, endTime: 900.75d).Object + }); + } + + [Test] + public void TestSlidersConcurrent() + { + assertConcurrentSame(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 300, endTime: 700.75d).Object + }); + } + + [Test] + public void TestSlidersAlmostConcurrent() + { + assertConcurrentSame(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 402, endTime: 902.75d).Object + }); + } + + [Test] + public void TestSliderAndCircleConcurrent() + { + assertConcurrentDifferent(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + new HitCircle { StartTime = 300 } + }); + } + + [Test] + public void TestManyObjectsConcurrent() + { + var hitobjects = new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 200, endTime: 500.75d).Object, + new HitCircle { StartTime = 300 } + }; + + var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList(); + + Assert.That(issues, Has.Count.EqualTo(3)); + Assert.That(issues.Where(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent).ToList(), Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame)); + } + + [Test] + public void TestHoldNotesSeparateOnSameColumn() + { + assertOk(new List + { + getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, + getHoldNoteMock(startTime: 500, endTime: 900.75d, column: 1).Object + }); + } + + [Test] + public void TestHoldNotesConcurrentOnDifferentColumns() + { + assertOk(new List + { + getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, + getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 2).Object + }); + } + + [Test] + public void TestHoldNotesConcurrentOnSameColumn() + { + assertConcurrentSame(new List + { + getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, + getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 1).Object + }); + } + + private Mock getSliderMock(double startTime, double endTime, int repeats = 0) + { + var mock = new Mock(); + mock.SetupGet(s => s.StartTime).Returns(startTime); + mock.As().Setup(r => r.RepeatCount).Returns(repeats); + mock.As().Setup(d => d.EndTime).Returns(endTime); + + return mock; + } + + private Mock getHoldNoteMock(double startTime, double endTime, int column) + { + var mock = new Mock(); + mock.SetupGet(s => s.StartTime).Returns(startTime); + mock.As().Setup(d => d.EndTime).Returns(endTime); + mock.As().Setup(c => c.Column).Returns(column); + + return mock; + } + + private void assertOk(List hitobjects) + { + Assert.That(check.Run(getPlayableBeatmap(hitobjects), null), Is.Empty); + } + + private void assertConcurrentSame(List hitobjects, int count = 1) + { + var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame)); + } + + private void assertConcurrentDifferent(List hitobjects, int count = 1) + { + var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent)); + } + + private IBeatmap getPlayableBeatmap(List hitobjects) + { + return new Beatmap + { + HitObjects = hitobjects + }; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckBackgroundTest.cs b/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs similarity index 61% rename from osu.Game.Tests/Editing/Checks/CheckBackgroundTest.cs rename to osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs index 635e3bb0f3..f6e875a8fc 100644 --- a/osu.Game.Tests/Editing/Checks/CheckBackgroundTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs @@ -5,21 +5,23 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.IO; using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Editing.Checks { [TestFixture] - public class CheckBackgroundTest + public class CheckFilePresenceTest { - private CheckBackground check; + private CheckBackgroundPresence check; private IBeatmap beatmap; [SetUp] public void Setup() { - check = new CheckBackground(); + check = new CheckBackgroundPresence(); beatmap = new Beatmap { BeatmapInfo = new BeatmapInfo @@ -29,7 +31,11 @@ namespace osu.Game.Tests.Editing.Checks { Files = new List(new[] { - new BeatmapSetFileInfo { Filename = "abc123.jpg" } + new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo { Hash = "abcdef" } + } }) } } @@ -39,7 +45,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestBackgroundSetAndInFiles() { - Assert.That(check.Run(beatmap), Is.Empty); + Assert.That(check.Run(beatmap, new TestWorkingBeatmap(beatmap)), Is.Empty); } [Test] @@ -47,10 +53,10 @@ namespace osu.Game.Tests.Editing.Checks { beatmap.BeatmapInfo.BeatmapSet.Files.Clear(); - var issues = check.Run(beatmap).ToList(); + var issues = check.Run(beatmap, new TestWorkingBeatmap(beatmap)).ToList(); Assert.That(issues, Has.Count.EqualTo(1)); - Assert.That(issues.Single().Template is CheckBackground.IssueTemplateDoesNotExist); + Assert.That(issues.Single().Template is CheckFilePresence.IssueTemplateDoesNotExist); } [Test] @@ -58,10 +64,10 @@ namespace osu.Game.Tests.Editing.Checks { beatmap.Metadata.BackgroundFile = null; - var issues = check.Run(beatmap).ToList(); + var issues = check.Run(beatmap, new TestWorkingBeatmap(beatmap)).ToList(); Assert.That(issues, Has.Count.EqualTo(1)); - Assert.That(issues.Single().Template is CheckBackground.IssueTemplateNoneSet); + Assert.That(issues.Single().Template is CheckFilePresence.IssueTemplateNoneSet); } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs new file mode 100644 index 0000000000..5e65b263f2 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckUnsnappedObjectsTest + { + private CheckUnsnappedObjects check; + private ControlPointInfo cpi; + + [SetUp] + public void Setup() + { + check = new CheckUnsnappedObjects(); + + cpi = new ControlPointInfo(); + cpi.Add(100, new TimingControlPoint { BeatLength = 100 }); + } + + [Test] + public void TestCircleSnapped() + { + assertOk(new List + { + new HitCircle { StartTime = 100 } + }); + } + + [Test] + public void TestCircleUnsnapped1Ms() + { + assert1Ms(new List + { + new HitCircle { StartTime = 101 } + }); + + assert1Ms(new List + { + new HitCircle { StartTime = 99 } + }); + } + + [Test] + public void TestCircleUnsnapped2Ms() + { + assert2Ms(new List + { + new HitCircle { StartTime = 102 } + }); + + assert2Ms(new List + { + new HitCircle { StartTime = 98 } + }); + } + + [Test] + public void TestSliderSnapped() + { + // Slider ends are naturally < 1 ms unsnapped because of how SV works. + assertOk(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object + }); + } + + [Test] + public void TestSliderUnsnapped1Ms() + { + assert1Ms(new List + { + getSliderMock(startTime: 101, endTime: 401.75d).Object + }, count: 2); + + // End is only off by 0.25 ms, hence count 1. + assert1Ms(new List + { + getSliderMock(startTime: 99, endTime: 399.75d).Object + }, count: 1); + } + + [Test] + public void TestSliderUnsnapped2Ms() + { + assert2Ms(new List + { + getSliderMock(startTime: 102, endTime: 402.75d).Object + }, count: 2); + + // Start and end are 2 ms and 1.25 ms off respectively, hence two different issues in one object. + var hitobjects = new List + { + getSliderMock(startTime: 98, endTime: 398.75d).Object + }; + + var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateSmallUnsnap)); + Assert.That(issues.Any(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateLargeUnsnap)); + } + + private Mock getSliderMock(double startTime, double endTime, int repeats = 0) + { + var mockSlider = new Mock(); + mockSlider.SetupGet(s => s.StartTime).Returns(startTime); + mockSlider.As().Setup(r => r.RepeatCount).Returns(repeats); + mockSlider.As().Setup(d => d.EndTime).Returns(endTime); + + return mockSlider; + } + + private void assertOk(List hitobjects) + { + Assert.That(check.Run(getPlayableBeatmap(hitobjects), null), Is.Empty); + } + + private void assert1Ms(List hitobjects, int count = 1) + { + var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateSmallUnsnap)); + } + + private void assert2Ms(List hitobjects, int count = 1) + { + var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateLargeUnsnap)); + } + + private IBeatmap getPlayableBeatmap(List hitobjects) + { + return new Beatmap + { + ControlPointInfo = cpi, + HitObjects = hitobjects + }; + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs new file mode 100644 index 0000000000..2e3f192f1b --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneDrawableHitObject : OsuTestScene + { + [Test] + public void TestEntryLifetime() + { + TestDrawableHitObject dho = null; + var initialHitObject = new HitObject + { + StartTime = 1000 + }; + var entry = new TestLifetimeEntry(new HitObject + { + StartTime = 2000 + }); + + AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject(initialHitObject)); + + AddAssert("Correct initial lifetime", () => dho.LifetimeStart == initialHitObject.StartTime - TestDrawableHitObject.INITIAL_LIFETIME_OFFSET); + + AddStep("Apply entry", () => dho.Apply(entry)); + + AddAssert("Correct initial lifetime", () => dho.LifetimeStart == entry.HitObject.StartTime - TestLifetimeEntry.INITIAL_LIFETIME_OFFSET); + + AddStep("Set lifetime", () => dho.LifetimeEnd = 3000); + AddAssert("Entry lifetime is updated", () => entry.LifetimeEnd == 3000); + } + + [Test] + public void TestKeepAlive() + { + TestDrawableHitObject dho = null; + TestLifetimeEntry entry = null; + AddStep("Create DHO", () => + { + dho = new TestDrawableHitObject(null); + dho.Apply(entry = new TestLifetimeEntry(new HitObject()) + { + LifetimeStart = 0, + LifetimeEnd = 1000, + }); + Child = dho; + }); + + AddStep("KeepAlive = true", () => entry.KeepAlive = true); + AddAssert("Lifetime is overriden", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == double.MaxValue); + + AddStep("Set LifetimeStart", () => dho.LifetimeStart = 500); + AddStep("KeepAlive = false", () => entry.KeepAlive = false); + AddAssert("Lifetime is correct", () => entry.LifetimeStart == 500 && entry.LifetimeEnd == 1000); + + AddStep("Set LifetimeStart while KeepAlive", () => + { + entry.KeepAlive = true; + dho.LifetimeStart = double.MinValue; + entry.KeepAlive = false; + }); + AddAssert("Lifetime is changed", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == 1000); + } + + private class TestDrawableHitObject : DrawableHitObject + { + public const double INITIAL_LIFETIME_OFFSET = 100; + protected override double InitialLifetimeOffset => INITIAL_LIFETIME_OFFSET; + + public TestDrawableHitObject(HitObject hitObject) + : base(hitObject) + { + } + } + + private class TestLifetimeEntry : HitObjectLifetimeEntry + { + public const double INITIAL_LIFETIME_OFFSET = 200; + protected override double InitialLifetimeOffset => INITIAL_LIFETIME_OFFSET; + + public TestLifetimeEntry(HitObject hitObject) + : base(hitObject) + { + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs deleted file mode 100644 index 891537c4ad..0000000000 --- a/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.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 NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Play; -using osu.Game.Tests.Visual; - -namespace osu.Game.Tests.Gameplay -{ - [HeadlessTest] - public class TestSceneGameplayClockContainer : OsuTestScene - { - [Test] - public void TestStartThenElapsedTime() - { - GameplayClockContainer gcc = null; - - AddStep("create container", () => - { - var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); - - Add(gcc = new GameplayClockContainer(working, 0)); - }); - - AddStep("start track", () => gcc.Start()); - AddUntilStep("elapsed greater than zero", () => gcc.GameplayClock.ElapsedFrameTime > 0); - } - } -} diff --git a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs new file mode 100644 index 0000000000..935bc07733 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneMasterGameplayClockContainer : OsuTestScene + { + [Test] + public void TestStartThenElapsedTime() + { + GameplayClockContainer gcc = null; + + AddStep("create container", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.LoadTrack(); + + Add(gcc = new MasterGameplayClockContainer(working, 0)); + }); + + AddStep("start clock", () => gcc.Start()); + AddUntilStep("elapsed greater than zero", () => gcc.GameplayClock.ElapsedFrameTime > 0); + } + + [Test] + public void TestElapseThenReset() + { + GameplayClockContainer gcc = null; + + AddStep("create container", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.LoadTrack(); + + Add(gcc = new MasterGameplayClockContainer(working, 0)); + }); + + AddStep("start clock", () => gcc.Start()); + AddUntilStep("current time greater 2000", () => gcc.GameplayClock.CurrentTime > 2000); + + double timeAtReset = 0; + AddStep("reset clock", () => + { + timeAtReset = gcc.GameplayClock.CurrentTime; + gcc.Reset(); + }); + + AddAssert("current time < time at reset", () => gcc.GameplayClock.CurrentTime < timeAtReset); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index cae5f20332..bbab9ae94d 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -20,6 +20,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -67,17 +68,47 @@ namespace osu.Game.Tests.Gameplay var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); working.LoadTrack(); - Add(gameplayContainer = new GameplayClockContainer(working, 0)); - - gameplayContainer.Add(sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + Add(gameplayContainer = new MasterGameplayClockContainer(working, 0) { - Clock = gameplayContainer.GameplayClock + IsPaused = { Value = true }, + Child = new FrameStabilityContainer + { + Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + } + }); + }); + + AddStep("reset clock", () => gameplayContainer.Start()); + + AddUntilStep("sample played", () => sample.RequestedPlaying); + AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); + } + + [Test] + public void TestSampleHasLifetimeEndWithInitialClockTime() + { + GameplayClockContainer gameplayContainer = null; + DrawableStoryboardSample sample = null; + + AddStep("create container", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.LoadTrack(); + + Add(gameplayContainer = new MasterGameplayClockContainer(working, 1000, true) + { + IsPaused = { Value = true }, + Child = new FrameStabilityContainer + { + Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + } }); }); AddStep("start time", () => gameplayContainer.Start()); - AddUntilStep("sample playback succeeded", () => sample.LifetimeEnd < double.MaxValue); + AddUntilStep("sample not played", () => !sample.RequestedPlaying); + AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); } [TestCase(typeof(OsuModDoubleTime), 1.5)] @@ -114,7 +145,7 @@ namespace osu.Game.Tests.Gameplay var beatmapSkinSourceContainer = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); - Add(gameplayContainer = new GameplayClockContainer(Beatmap.Value, 0) + Add(gameplayContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) { Child = beatmapSkinSourceContainer }); diff --git a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs index 883c9d1ac2..dd105787fa 100644 --- a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs +++ b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs @@ -4,7 +4,10 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; +using osu.Game.Overlays.Settings; namespace osu.Game.Tests.Mods { @@ -26,6 +29,16 @@ namespace osu.Game.Tests.Mods Assert.That(orderedSettings[3].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.UnorderedSetting))); } + [Test] + public void TestCustomControl() + { + var objectWithCustomSettingControl = new ClassWithCustomSettingControl(); + var settings = objectWithCustomSettingControl.CreateSettingsControls().ToArray(); + + Assert.That(settings, Has.Length.EqualTo(1)); + Assert.That(settings[0], Is.TypeOf()); + } + private class ClassWithSettings { [SettingSource("Unordered setting", "Should be last")] @@ -40,5 +53,21 @@ namespace osu.Game.Tests.Mods [SettingSource("Third setting", "Yet another description", 3)] public BindableInt ThirdSetting { get; set; } = new BindableInt(); } + + private class ClassWithCustomSettingControl + { + [SettingSource("Custom setting", "Should be a custom control", SettingControlType = typeof(CustomSettingsControl))] + public BindableInt UnorderedSetting { get; set; } = new BindableInt(); + } + + private class CustomSettingsControl : SettingsItem + { + protected override Drawable CreateControl() => new CustomControl(); + + private class CustomControl : Drawable, IHasCurrentValue + { + public Bindable Current { get; set; } = new Bindable(); + } + } } } diff --git a/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs new file mode 100644 index 0000000000..08cd80dcfa --- /dev/null +++ b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.NonVisual +{ + public class ClosestBeatDivisorTest + { + [Test] + public void TestExactDivisors() + { + var cpi = new ControlPointInfo(); + cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 }); + + double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 }; + + assertClosestDivisors(divisors, divisors, cpi); + } + + [Test] + public void TestExactDivisorWithTempoChanges() + { + int offset = 0; + int[] beatLengths = { 1000, 200, 100, 50 }; + + var cpi = new ControlPointInfo(); + + foreach (int beatLength in beatLengths) + { + cpi.Add(offset, new TimingControlPoint { BeatLength = beatLength }); + offset += beatLength * 2; + } + + double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3 }; + + assertClosestDivisors(divisors, divisors, cpi); + } + + [Test] + public void TestExactDivisorsHighBPMStream() + { + var cpi = new ControlPointInfo(); + cpi.Add(-50, new TimingControlPoint { BeatLength = 50 }); // 1200 BPM 1/4 (limit testing) + + // A 1/4 stream should land on 1/1, 1/2 and 1/4 divisors. + double[] divisors = { 4, 4, 4, 4, 4, 4, 4, 4 }; + double[] closestDivisors = { 4, 2, 4, 1, 4, 2, 4, 1 }; + + assertClosestDivisors(divisors, closestDivisors, cpi, step: 1 / 4d); + } + + [Test] + public void TestApproximateDivisors() + { + var cpi = new ControlPointInfo(); + cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 }); + + double[] divisors = { 3.03d, 0.97d, 14, 13, 7.94d, 6.08d, 3.93d, 2.96d, 2.02d, 64 }; + double[] closestDivisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 }; + + assertClosestDivisors(divisors, closestDivisors, cpi); + } + + private void assertClosestDivisors(IReadOnlyList divisors, IReadOnlyList closestDivisors, ControlPointInfo cpi, double step = 1) + { + List hitobjects = new List(); + double offset = cpi.TimingPoints[0].Time; + + for (int i = 0; i < divisors.Count; ++i) + { + double beatLength = cpi.TimingPointAt(offset).BeatLength; + hitobjects.Add(new HitObject { StartTime = offset + beatLength / divisors[i] }); + offset += beatLength * step; + } + + var beatmap = new Beatmap + { + HitObjects = hitobjects, + ControlPointInfo = cpi + }; + + for (int i = 0; i < divisors.Count; ++i) + Assert.AreEqual(closestDivisors[i], beatmap.ControlPointInfo.GetClosestBeatDivisor(beatmap.HitObjects[i].StartTime), $"at index {i}"); + } + } +} diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index a42b7d54ee..407dec936b 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Game.Replays; using osu.Game.Rulesets.Replays; @@ -278,6 +279,54 @@ namespace osu.Game.Tests.NonVisual setTime(-100, -100); } + [Test] + public void TestReplayFramesSortStability() + { + const double repeating_time = 5000; + + // add a collection of frames in shuffled order time-wise; each frame also stores its original index to check stability later. + // data is hand-picked and breaks if the unstable List.Sort() is used. + // in theory this can still return a false-positive with another unstable algorithm if extremely unlucky, + // but there is no conceivable fool-proof way to prevent that anyways. + replay.Frames.AddRange(new[] + { + repeating_time, + 0, + 3000, + repeating_time, + repeating_time, + 6000, + 9000, + repeating_time, + repeating_time, + 1000, + 11000, + 21000, + 4000, + repeating_time, + repeating_time, + 8000, + 2000, + 7000, + repeating_time, + repeating_time, + 10000 + }.Select((time, index) => new TestReplayFrame(time, true, index))); + + replay.HasReceivedAllFrames = true; + + // create a new handler with the replay for the sort to be performed. + handler = new TestInputHandler(replay); + + // ensure sort stability by checking that the frames with time == repeating_time are sorted in ascending frame index order themselves. + var repeatingTimeFramesData = replay.Frames + .Cast() + .Where(f => f.Time == repeating_time) + .Select(f => f.FrameIndex); + + Assert.That(repeatingTimeFramesData, Is.Ordered.Ascending); + } + private void setReplayFrames() { replay.Frames = new List @@ -324,11 +373,13 @@ namespace osu.Game.Tests.NonVisual private class TestReplayFrame : ReplayFrame { public readonly bool IsImportant; + public readonly int FrameIndex; - public TestReplayFrame(double time, bool isImportant = false) + public TestReplayFrame(double time, bool isImportant = false, int frameIndex = 0) : base(time) { IsImportant = isImportant; + FrameIndex = frameIndex; } } diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index a2ad37cf4a..377a33b527 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -41,6 +41,32 @@ namespace osu.Game.Tests.NonVisual.Multiplayer checkPlayingUserCount(0); } + [Test] + public void TestPlayingUsersUpdatedOnJoin() + { + AddStep("leave room", () => Client.LeaveRoom()); + AddUntilStep("wait for room part", () => Client.Room == null); + + AddStep("create room initially in gameplay", () => + { + Room.RoomID.Value = null; + Client.RoomSetupAction = room => + { + room.State = MultiplayerRoomState.Playing; + room.Users.Add(new MultiplayerRoomUser(55) + { + User = new User { Id = 55 }, + State = MultiplayerUserState.Playing + }); + }; + + RoomManager.CreateRoom(Room); + }); + + AddUntilStep("wait for room join", () => Client.Room != null); + checkPlayingUserCount(1); + } + private void checkPlayingUserCount(int expectedCount) => AddAssert($"{"user".ToQuantity(expectedCount)} playing", () => Client.CurrentMatchPlayingUserIds.Count == expectedCount); diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 20fa0732b9..184a94912a 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; @@ -51,7 +50,7 @@ namespace osu.Game.Tests.Rulesets.Scoring }; scoreProcessor.ApplyResult(judgementResult); - Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); } /// @@ -84,8 +83,8 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 925_000)] // (3 * 10) / (4 * 10) * 300_000 + 700_000 (max combo 0) [TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (3 * 0) / (4 * 30) * 300_000 + (0 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 575_000)] // (3 * 30) / (4 * 30) * 300_000 + (0 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 700_030)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 700_150)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] // (0 * 4 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 156)] // (((3 * 50) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 312)] // (((3 * 100) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) @@ -96,8 +95,9 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 225)] // (((3 * 10) / (4 * 10)) * 1 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (0 * 4 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 936)] // (((3 * 50) / (4 * 50)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 30)] // (0 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 150)] // (0 * 1 * 300) * (1 + 0 / 25) * 3 * 50 (bonus points) + // TODO: The following two cases don't match expectations currently (a single hit is registered in acc portion when it shouldn't be). See https://github.com/ppy/osu/issues/12604. + [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 330)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points) + [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 450)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 50 (bonus points) public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) { var minResult = new TestJudgement(hitResult).MinResult; @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Rulesets.Scoring scoreProcessor.ApplyResult(judgementResult); } - Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); } /// @@ -158,7 +158,7 @@ namespace osu.Game.Tests.Rulesets.Scoring }; scoreProcessor.ApplyResult(lastJudgementResult); - Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); } [Test] @@ -169,7 +169,7 @@ namespace osu.Game.Tests.Rulesets.Scoring scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo())); - Assert.IsTrue(Precision.AlmostEquals(0, scoreProcessor.TotalScore.Value)); + Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); } [TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)] @@ -287,6 +287,23 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); } + [TestCase(HitResult.Perfect, 1_000_000)] + [TestCase(HitResult.SmallTickHit, 1_000_000)] + [TestCase(HitResult.LargeTickHit, 1_000_000)] + [TestCase(HitResult.SmallBonus, 1_000_000 + Judgement.SMALL_BONUS_SCORE)] + [TestCase(HitResult.LargeBonus, 1_000_000 + Judgement.LARGE_BONUS_SCORE)] + public void TestGetScoreWithExternalStatistics(HitResult result, int expectedScore) + { + var statistic = new Dictionary { { result, 1 } }; + + scoreProcessor.ApplyBeatmap(new Beatmap + { + HitObjects = { new TestHitObject(result) } + }); + + Assert.That(scoreProcessor.GetImmediateScore(ScoringMode.Standardised, result.AffectsCombo() ? 1 : 0, statistic), Is.EqualTo(expectedScore).Within(0.5d)); + } + private class TestJudgement : Judgement { public override HitResult MaxResult { get; } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs index fd9c09fd5f..976bf93c15 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs @@ -23,8 +23,8 @@ namespace osu.Game.Tests.Visual.Editing protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - private BlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); + private EditorBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); [Test] public void TestSelectedObjectHasPriorityWhenOverlapping() diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index 01d9966736..3aff74a0a8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -132,8 +132,8 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("deselect", () => EditorBeatmap.SelectedHitObjects.Clear()); - AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0); - AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0); + AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0); + AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0); } AddStep("paste hitobject", () => Editor.Paste()); @@ -142,8 +142,8 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000); - AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); - AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); + AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); + AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs index 99f31b0c2a..0758d73c2a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Tests.Beatmaps; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -25,15 +26,15 @@ namespace osu.Game.Tests.Visual.Editing protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - private BlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); + private EditorBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); private void moveMouseToObject(Func targetFunc) { AddStep("move mouse to object", () => { var pos = blueprintContainer.SelectionBlueprints - .First(s => s.HitObject == targetFunc()) + .First(s => s.Item == targetFunc()) .ChildrenOfType() .First().ScreenSpaceDrawQuad.Centre; @@ -41,6 +42,28 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestNudgeSelection() + { + HitCircle[] addedObjects = null; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(50) }, + new HitCircle { StartTime = 300, Position = new Vector2(100) }, + new HitCircle { StartTime = 400, Position = new Vector2(150) }, + })); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("nudge forwards", () => InputManager.Key(Key.K)); + AddAssert("objects moved forwards in time", () => addedObjects[0].StartTime > 100); + + AddStep("nudge backwards", () => InputManager.Key(Key.J)); + AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); + } + [Test] public void TestBasicSelect() { @@ -156,9 +179,35 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestQuickDeleteRemovesObject() + public void TestQuickDeleteRemovesObjectInPlacement() { - var addedObject = new HitCircle { StartTime = 1000 }; + var addedObject = new HitCircle + { + StartTime = 0, + Position = OsuPlayfield.BASE_SIZE * 0.5f + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("enter placement mode", () => InputManager.PressKey(Key.Number2)); + + moveMouseToObject(() => addedObject); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestQuickDeleteRemovesObjectInSelection() + { + var addedObject = new HitCircle + { + StartTime = 0, + Position = OsuPlayfield.BASE_SIZE * 0.5f + }; AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index dccde366c2..f5f17a0bc1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -130,9 +130,9 @@ namespace osu.Game.Tests.Visual.Gameplay public double GameplayClockTime => GameplayClockContainer.GameplayClock.CurrentTime; - protected override void UpdateAfterChildren() + protected override void Update() { - base.UpdateAfterChildren(); + base.Update(); if (!FirstFrameClockTime.HasValue) { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs new file mode 100644 index 0000000000..0c2c6ed454 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Skinning.Editor; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinEditor : PlayerTestScene + { + private SkinEditor skinEditor; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add editor overlay", () => + { + skinEditor?.Expire(); + LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); + }); + } + + [Test] + public void TestToggleEditor() + { + AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility()); + } + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs new file mode 100644 index 0000000000..086bcb19c3 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Skinning.Editor; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinEditorMultipleSkins : SkinnableTestScene + { + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create editor overlay", () => + { + SetContents(() => + { + var ruleset = new OsuRuleset(); + var working = CreateWorkingBeatmap(ruleset.RulesetInfo); + var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo); + + ScoreProcessor scoreProcessor = new ScoreProcessor(); + + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); + + var hudOverlay = new HUDOverlay(scoreProcessor, null, drawableRuleset, Array.Empty()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + // Add any key just to display the key counter visually. + hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + hudOverlay.ComboCounter.Current.Value = 1; + + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + drawableRuleset, + hudOverlay, + new SkinEditor(hudOverlay), + } + }; + }); + }); + } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index 841722a8f1..e08e03b789 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.Gameplay var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); working.LoadTrack(); - Child = gameplayClockContainer = new GameplayClockContainer(working, 0) + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("click", () => { - increment = skip_time - gameplayClock.CurrentTime - GameplayClockContainer.MINIMUM_SKIP_TIME / 2; + increment = skip_time - gameplayClock.CurrentTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME / 2; InputManager.Click(MouseButton.Left); }); AddStep("click", () => InputManager.Click(MouseButton.Left)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 397b37718d..74ce66096e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -288,7 +288,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override void WatchUser(int userId) { - if (sentState) + if (!PlayingUsers.Contains(userId) && sentState) { // usually the server would do this. sendState(beatmapId); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs new file mode 100644 index 0000000000..4138a81ebd --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -0,0 +1,191 @@ +// 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.Tasks; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Storyboards; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneStoryboardWithOutro : PlayerTestScene + { + protected override bool HasCustomSteps => true; + + protected new OutroPlayer Player => (OutroPlayer)base.Player; + + private double currentStoryboardDuration; + + private bool showResults = true; + + private event Func currentFailConditions; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true)); + AddStep("set dim level to 0", () => LocalConfig.SetValue(OsuSetting.DimLevel, 0)); + AddStep("reset fail conditions", () => currentFailConditions = (_, __) => false); + AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000); + AddStep("set ShowResults = true", () => showResults = true); + } + + [Test] + public void TestStoryboardSkipOutro() + { + CreateTest(null); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space)); + AddAssert("score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestStoryboardNoSkipOutro() + { + CreateTest(null); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestStoryboardExitToSkipOutro() + { + CreateTest(null); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddStep("exit via pause", () => Player.ExitViaPause()); + AddAssert("score shown", () => Player.IsScoreShown); + } + + [TestCase(false)] + [TestCase(true)] + public void TestStoryboardToggle(bool enabledAtBeginning) + { + CreateTest(null); + AddStep($"{(enabledAtBeginning ? "enable" : "disable")} storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, enabledAtBeginning)); + AddStep("toggle storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, !enabledAtBeginning)); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestOutroEndsDuringFailAnimation() + { + CreateTest(() => + { + AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); + AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300); + }); + AddUntilStep("wait for fail", () => Player.HasFailed); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); + } + + [Test] + public void TestShowResultsFalse() + { + CreateTest(() => + { + AddStep("set ShowResults = false", () => showResults = false); + }); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddWaitStep("wait", 10); + AddAssert("no score shown", () => !Player.IsScoreShown); + } + + [Test] + public void TestStoryboardEndsBeforeCompletion() + { + CreateTest(() => AddStep("set storyboard duration to .1s", () => currentStoryboardDuration = 100)); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestStoryboardRewind() + { + SkipOverlay.FadeContainer fadeContainer() => Player.ChildrenOfType().First(); + + CreateTest(null); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); + + AddStep("rewind", () => Player.GameplayClockContainer.Seek(-1000)); + AddUntilStep("skip overlay content not visible", () => fadeContainer().State == Visibility.Hidden); + + AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + } + + protected override bool AllowFail => true; + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OutroPlayer(currentFailConditions, showResults); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap(); + beatmap.HitObjects.Add(new HitCircle()); + return beatmap; + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + { + return base.CreateWorkingBeatmap(beatmap, createStoryboard(currentStoryboardDuration)); + } + + private Storyboard createStoryboard(double duration) + { + var storyboard = new Storyboard(); + var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); + sprite.TimelineGroup.Alpha.Add(Easing.None, 0, duration, 1, 0); + storyboard.GetLayer("Background").Add(sprite); + return storyboard; + } + + protected class OutroPlayer : TestPlayer + { + public void ExitViaPause() => PerformExit(true); + + public new FailOverlay FailOverlay => base.FailOverlay; + + public bool IsScoreShown => !this.IsCurrentScreen() && this.GetChildScreen() is ResultsScreen; + + private event Func failConditions; + + public OutroPlayer(Func failConditions, bool showResults = true) + : base(false, showResults) + { + this.failConditions = failConditions; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + HealthProcessor.FailConditions += failConditions; + } + + protected override Task ImportScore(Score score) + { + return Task.CompletedTask; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 0c199bfb62..3fc894da0d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -43,7 +43,10 @@ namespace osu.Game.Tests.Visual.Online createButtonWithBeatmap(createSoleily()); AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport())); + AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineBeatmapSetID == 241526)); + AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable); + createButtonWithBeatmap(createSoleily()); AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable); ensureSoleilyRemoved(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 01e67b1681..165fff99dd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -83,6 +83,28 @@ namespace osu.Game.Tests.Visual.Online }; }); + [Test] + public void TestSystemMessageOrdering() + { + var standardMessage = new Message(messageIdSequence++) + { + Sender = admin, + Content = "I am a wang!" + }; + + var infoMessage1 = new InfoMessage($"the system is calling {messageIdSequence++}"); + var infoMessage2 = new InfoMessage($"the system is calling {messageIdSequence++}"); + + AddStep("message from admin", () => testChannel.AddNewMessages(standardMessage)); + AddStep("message from system", () => testChannel.AddNewMessages(infoMessage1)); + AddStep("message from system", () => testChannel.AddNewMessages(infoMessage2)); + + AddAssert("message order is correct", () => testChannel.Messages.Count == 3 + && testChannel.Messages[0] == standardMessage + && testChannel.Messages[1] == infoMessage1 + && testChannel.Messages[2] == infoMessage2); + } + [Test] public void TestManyMessages() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index 7ea6373763..a416fd4daf 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -7,6 +7,7 @@ using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; @@ -107,13 +108,13 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("check version", () => infoWedge.Info.VersionLabel.Current.Value == $"{ruleset.ShortName}Version"); AddAssert("check title", () => infoWedge.Info.TitleLabel.Current.Value == $"{ruleset.ShortName}Source — {ruleset.ShortName}Title"); AddAssert("check artist", () => infoWedge.Info.ArtistLabel.Current.Value == $"{ruleset.ShortName}Artist"); - AddAssert("check author", () => infoWedge.Info.MapperContainer.Children.OfType().Any(s => s.Current.Value == $"{ruleset.ShortName}Author")); + AddAssert("check author", () => infoWedge.Info.MapperContainer.ChildrenOfType().Any(s => s.Current.Value == $"{ruleset.ShortName}Author")); } private void testInfoLabels(int expectedCount) { - AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType().Any()); - AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType().Count() == expectedCount); + AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType().Any()); + AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType().Count() == expectedCount); } [Test] @@ -123,8 +124,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("check empty version", () => string.IsNullOrEmpty(infoWedge.Info.VersionLabel.Current.Value)); AddAssert("check default title", () => infoWedge.Info.TitleLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Title); AddAssert("check default artist", () => infoWedge.Info.ArtistLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Artist); - AddAssert("check empty author", () => !infoWedge.Info.MapperContainer.Children.Any()); - AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any()); + AddAssert("check empty author", () => !infoWedge.Info.MapperContainer.ChildrenOfType().Any()); + AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any()); } [Test] @@ -135,15 +136,15 @@ namespace osu.Game.Tests.Visual.SongSelect private void selectBeatmap([CanBeNull] IBeatmap b) { - BeatmapInfoWedge.BufferedWedgeInfo infoBefore = null; + Container containerBefore = null; AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => { - infoBefore = infoWedge.Info; + containerBefore = infoWedge.DisplayedContent; infoWedge.Beatmap = Beatmap.Value = b == null ? Beatmap.Default : CreateWorkingBeatmap(b); }); - AddUntilStep("wait for async load", () => infoWedge.Info != infoBefore); + AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); } private IBeatmap createTestBeatmap(RulesetInfo ruleset) @@ -193,7 +194,9 @@ namespace osu.Game.Tests.Visual.SongSelect private class TestBeatmapInfoWedge : BeatmapInfoWedge { - public new BufferedWedgeInfo Info => base.Info; + public new Container DisplayedContent => base.DisplayedContent; + + public new WedgeInfoText Info => base.Info; } private class TestHitObject : ConvertHitObject, IHasPosition diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index 2158cf77e5..bda1973354 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -167,7 +167,7 @@ namespace osu.Game.Tests.Visual.UserInterface GetModButton(mod).SelectNext(1); public void SetModSettingsWidth(float newWidth) => - ModSettingsContainer.Width = newWidth; + ModSettingsContainer.Parent.Width = newWidth; } public class TestRulesetInfo : RulesetInfo diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 8c8c827404..cbed28641c 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -52,6 +52,8 @@ namespace osu.Game.Tests protected override Waveform GetWaveform() => new Waveform(trackStore.GetStream(firstAudioFile)); + public override Stream GetStream(string storagePath) => null; + protected override Track GetBeatmapTrack() => trackStore.Get(firstAudioFile); private string firstAudioFile diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index df6d17f615..895518e1b9 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index a4e52f8cd4..d5dda39aa5 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index b4ea898b7d..5e975de77c 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -526,6 +526,7 @@ namespace osu.Game.Beatmaps protected override IBeatmap GetBeatmap() => beatmap; protected override Texture GetBackground() => null; protected override Track GetBeatmapTrack() => null; + public override Stream GetStream(string storagePath) => null; } } diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs index 62cf29dc03..d78ffbbfb6 100644 --- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs @@ -3,7 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; +using System.IO; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; @@ -36,7 +36,7 @@ namespace osu.Game.Beatmaps try { - using (var stream = new LineBufferedReader(resources.Files.GetStream(getPathForFile(BeatmapInfo.Path)))) + using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path)))) return Decoder.GetDecoder(stream).Decode(stream); } catch (Exception e) @@ -46,8 +46,6 @@ namespace osu.Game.Beatmaps } } - private string getPathForFile(string filename) => BeatmapSetInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; - protected override bool BackgroundStillValid(Texture b) => false; // bypass lazy logic. we want to return a new background each time for refcounting purposes. protected override Texture GetBackground() @@ -57,7 +55,7 @@ namespace osu.Game.Beatmaps try { - return resources.LargeTextureStore.Get(getPathForFile(Metadata.BackgroundFile)); + return resources.LargeTextureStore.Get(BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile)); } catch (Exception e) { @@ -73,7 +71,7 @@ namespace osu.Game.Beatmaps try { - return resources.Tracks.Get(getPathForFile(Metadata.AudioFile)); + return resources.Tracks.Get(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); } catch (Exception e) { @@ -89,7 +87,7 @@ namespace osu.Game.Beatmaps try { - var trackData = resources.Files.GetStream(getPathForFile(Metadata.AudioFile)); + var trackData = GetStream(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); return trackData == null ? null : new Waveform(trackData); } catch (Exception e) @@ -105,7 +103,7 @@ namespace osu.Game.Beatmaps try { - using (var stream = new LineBufferedReader(resources.Files.GetStream(getPathForFile(BeatmapInfo.Path)))) + using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path)))) { var decoder = Decoder.GetDecoder(stream); @@ -114,7 +112,7 @@ namespace osu.Game.Beatmaps storyboard = decoder.Decode(stream); else { - using (var secondaryStream = new LineBufferedReader(resources.Files.GetStream(getPathForFile(BeatmapSetInfo.StoryboardFile)))) + using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapSetInfo.StoryboardFile)))) storyboard = decoder.Decode(stream, secondaryStream); } } @@ -142,6 +140,8 @@ namespace osu.Game.Beatmaps return null; } } + + public override Stream GetStream(string storagePath) => resources.Files.GetStream(storagePath); } } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 7bc1c8c7b9..1ce42535a0 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -59,6 +59,13 @@ namespace osu.Game.Beatmaps public string StoryboardFile => Files?.Find(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// + /// The name of the file to get the storage path of. + public string GetPathForFile(string filename) => Files?.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + public List Files { get; set; } public override string ToString() => Metadata?.ToString() ?? base.ToString(); diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs index 5864975a2e..ae5a44cfcd 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs @@ -14,4 +14,10 @@ namespace osu.Game.Beatmaps Qualified = 3, Loved = 4, } + + public static class BeatmapSetOnlineStatusExtensions + { + public static bool GrantsPerformancePoints(this BeatmapSetOnlineStatus status) + => status == BeatmapSetOnlineStatus.Ranked || status == BeatmapSetOnlineStatus.Approved; + } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 5cc60a5758..d3a4b635f5 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -7,6 +7,8 @@ using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Lists; +using osu.Framework.Utils; +using osu.Game.Screens.Edit; namespace osu.Game.Beatmaps.ControlPoints { @@ -160,6 +162,58 @@ namespace osu.Game.Beatmaps.ControlPoints groups.Remove(group); } + /// + /// Returns the time on the given beat divisor closest to the given time. + /// + /// The time to find the closest snapped time to. + /// The beat divisor to snap to. + /// An optional reference point to use for timing point lookup. + public double GetClosestSnappedTime(double time, int beatDivisor, double? referenceTime = null) + { + var timingPoint = TimingPointAt(referenceTime ?? time); + return getClosestSnappedTime(timingPoint, time, beatDivisor); + } + + /// + /// Returns the time on *ANY* valid beat divisor, favouring the divisor closest to the given time. + /// + /// The time to find the closest snapped time to. + public double GetClosestSnappedTime(double time) => GetClosestSnappedTime(time, GetClosestBeatDivisor(time)); + + /// + /// Returns the beat snap divisor closest to the given time. If two are equally close, the smallest divisor is returned. + /// + /// The time to find the closest beat snap divisor to. + /// An optional reference point to use for timing point lookup. + public int GetClosestBeatDivisor(double time, double? referenceTime = null) + { + TimingControlPoint timingPoint = TimingPointAt(referenceTime ?? time); + + int closestDivisor = 0; + double closestTime = double.MaxValue; + + foreach (int divisor in BindableBeatDivisor.VALID_DIVISORS) + { + double distanceFromSnap = Math.Abs(time - getClosestSnappedTime(timingPoint, time, divisor)); + + if (Precision.DefinitelyBigger(closestTime, distanceFromSnap)) + { + closestDivisor = divisor; + closestTime = distanceFromSnap; + } + } + + return closestDivisor; + } + + private static double getClosestSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor) + { + var beatLength = timingPoint.BeatLength / beatDivisor; + var beatLengths = (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero); + + return timingPoint.Time + beatLengths * beatLength; + } + /// /// Binary searches one of the control point lists to find the active control point at . /// Includes logic for returning a specific point when no matching point is found. diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index c114358771..6922d1c286 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading; using JetBrains.Annotations; using osu.Framework.Audio; @@ -48,6 +49,8 @@ namespace osu.Game.Beatmaps protected override Track GetBeatmapTrack() => GetVirtualTrack(); + public override Stream GetStream(string storagePath) => null; + private class DummyRulesetInfo : RulesetInfo { public override Ruleset CreateInstance() => new DummyRuleset(); diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index bcd94d76fd..a916b37b85 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Rulesets; @@ -41,6 +42,11 @@ namespace osu.Game.Beatmaps /// ISkin Skin { get; } + /// + /// Retrieves the which this has loaded. + /// + Track Track { get; } + /// /// Constructs a playable from using the applicable converters for a specific . /// @@ -67,5 +73,11 @@ namespace osu.Game.Beatmaps /// /// A fresh track instance, which will also be available via . Track LoadTrack(); + + /// + /// Returns the stream of the file from the given storage path. + /// + /// The storage path to the file. + Stream GetStream(string storagePath); } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index f7f276230f..e0eeaf6db0 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -326,6 +327,8 @@ namespace osu.Game.Beatmaps protected virtual ISkin GetSkin() => new DefaultSkin(); private readonly RecyclableLazy skin; + public abstract Stream GetStream(string storagePath); + public class RecyclableLazy { private Lazy lazy; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index f9b1c9618b..09412b1f1b 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -143,6 +143,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f); + SetDefault(OsuSetting.EditorHitAnimations, false); } public OsuConfigManager(Storage storage) @@ -266,6 +267,7 @@ namespace osu.Game.Configuration GameplayDisableWinKey, SeasonalBackgroundMode, EditorWaveformOpacity, + EditorHitAnimations, DiscordRichPresence, AutomaticallyDownloadWhenSpectating, ShowOnlineExplicitContent, diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index cfce615130..3e50613093 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using JetBrains.Annotations; using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Overlays.Settings; @@ -31,7 +34,15 @@ namespace osu.Game.Configuration public int? OrderPosition { get; } - public SettingSourceAttribute(string label, string description = null) + /// + /// The type of the settings control which handles this setting source. + /// + /// + /// Must be a type deriving with a public parameterless constructor. + /// + public Type? SettingControlType { get; set; } + + public SettingSourceAttribute(string? label, string? description = null) { Label = label ?? string.Empty; Description = description ?? string.Empty; @@ -67,6 +78,22 @@ namespace osu.Game.Configuration { object value = property.GetValue(obj); + if (attr.SettingControlType != null) + { + var controlType = attr.SettingControlType; + if (controlType.EnumerateBaseTypes().All(t => !t.IsGenericType || t.GetGenericTypeDefinition() != typeof(SettingsItem<>))) + throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})"); + + var control = (Drawable)Activator.CreateInstance(controlType); + controlType.GetProperty(nameof(SettingsItem.LabelText))?.SetValue(control, attr.Label); + controlType.GetProperty(nameof(SettingsItem.TooltipText))?.SetValue(control, attr.Description); + controlType.GetProperty(nameof(SettingsItem.Current))?.SetValue(control, value); + + yield return control; + + continue; + } + switch (value) { case BindableNumber bNumber: diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index d809dbcb01..dbeaebb1cd 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -156,33 +156,44 @@ namespace osu.Game.Database bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; - await Task.WhenAll(tasks.Select(async task => + try { - notification.CancellationToken.ThrowIfCancellationRequested(); - - try + await Task.WhenAll(tasks.Select(async task => { - var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); + notification.CancellationToken.ThrowIfCancellationRequested(); - lock (imported) + try { - if (model != null) - imported.Add(model); - current++; + var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); - notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; - notification.Progress = (float)current / tasks.Length; + lock (imported) + { + if (model != null) + imported.Add(model); + current++; + + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } } - } - catch (TaskCanceledException) + catch (TaskCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + })).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (imported.Count == 0) { - throw; + notification.State = ProgressNotificationState.Cancelled; + return imported; } - catch (Exception e) - { - Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); - } - })).ConfigureAwait(false); + } if (imported.Count == 0) { @@ -422,15 +433,25 @@ namespace osu.Game.Database if (retrievedItem == null) throw new ArgumentException("Specified model could not be found", nameof(item)); + using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) + ExportModelTo(retrievedItem, outputStream); + + exportStorage.OpenInNativeExplorer(); + } + + /// + /// Exports an item to the given output stream. + /// + /// The item to export. + /// The output stream to export to. + protected virtual void ExportModelTo(TModel model, Stream outputStream) + { using (var archive = ZipArchive.Create()) { - foreach (var file in retrievedItem.Files) + foreach (var file in model.Files) archive.AddEntry(file.Filename, Files.Storage.GetStream(file.FileInfo.StoragePath)); - using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) - archive.SaveTo(outputStream); - - exportStorage.OpenInNativeExplorer(); + archive.SaveTo(outputStream); } } diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 67b9e727a5..a8de3f6407 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Threading; +using osuTK; namespace osu.Game.Extensions { @@ -32,5 +34,14 @@ namespace osu.Game.Extensions scheduler.Add(repeatDelegate); return repeatDelegate; } + + /// + /// Accepts a delta vector in screen-space coordinates and converts it to one which can be applied to this drawable's position. + /// + /// The drawable. + /// A delta in screen-space coordinates. + /// The delta vector in Parent's coordinates. + public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) => + drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta); } } diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index e168f265dd..c0518247a9 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -28,7 +28,7 @@ namespace osu.Game.Graphics.Containers protected override bool BlockNonPositionalInput => true; /// - /// Temporary to allow for overlays in the main screen content to not dim theirselves. + /// Temporary to allow for overlays in the main screen content to not dim themselves. /// Should be eventually replaced by dimming which is aware of the target dim container (traverse parent for certain interface type?). /// protected virtual bool DimMainContent => true; diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 8f07c3a656..2488fd14d0 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -36,6 +36,24 @@ namespace osu.Game.Graphics.Containers private BackgroundScreenStack backgroundStack; + private bool allowScaling = true; + + /// + /// Whether user scaling preferences should be applied. Enabled by default. + /// + public bool AllowScaling + { + get => allowScaling; + set + { + if (value == allowScaling) + return; + + allowScaling = value; + if (IsLoaded) updateSize(); + } + } + /// /// Create a new instance. /// @@ -139,7 +157,7 @@ namespace osu.Game.Graphics.Containers backgroundStack?.FadeOut(fade_time); } - bool scaling = targetMode == null || scalingMode.Value == targetMode; + bool scaling = AllowScaling && (targetMode == null || scalingMode.Value == targetMode); var targetSize = scaling ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One; var targetPosition = scaling ? new Vector2(posX.Value, posY.Value) * (Vector2.One - targetSize) : Vector2.Zero; diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index e414e12dd1..ce945f3bf8 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -48,6 +48,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing), new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), + new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.ToggleSkinEditor), new KeyBinding(InputKey.Escape, GlobalAction.Back), new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), @@ -71,6 +72,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode), new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), + new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), + new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), }; public IEnumerable InGameKeyBindings => new[] @@ -251,5 +254,14 @@ namespace osu.Game.Input.Bindings [Description("Verify mode")] EditorVerifyMode, + + [Description("Nudge selection left")] + EditorNudgeLeft, + + [Description("Nudge selection right")] + EditorNudgeRight, + + [Description("Toggle skin editor")] + ToggleSkinEditor, } } diff --git a/osu.Game/Online/Chat/InfoMessage.cs b/osu.Game/Online/Chat/InfoMessage.cs index 8dce188804..cea336aae2 100644 --- a/osu.Game/Online/Chat/InfoMessage.cs +++ b/osu.Game/Online/Chat/InfoMessage.cs @@ -8,10 +8,8 @@ namespace osu.Game.Online.Chat { public class InfoMessage : LocalMessage { - private static int infoID = -1; - public InfoMessage(string message) - : base(infoID--) + : base(null) { Timestamp = DateTimeOffset.Now; Content = message; diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index 2e41038a59..30753b3920 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -59,7 +59,7 @@ namespace osu.Game.Online.Chat return Id.Value.CompareTo(other.Id.Value); } - public virtual bool Equals(Message other) => Id == other?.Id; + public virtual bool Equals(Message other) => Id.HasValue && Id == other?.Id; // ReSharper disable once ImpureMethodCallOnReadonlyValueField public override int GetHashCode() => Id.GetHashCode(); diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs index 2ddc10db0f..c0706b082d 100644 --- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs @@ -144,6 +144,8 @@ namespace osu.Game.Online.Multiplayer Room = joinedRoom; apiRoom = room; defaultPlaylistItemId = apiRoom.Playlist.FirstOrDefault()?.ID ?? 0; + foreach (var user in joinedRoom.Users) + updateUserPlayingState(user.UserID, user.State); }, cancellationSource.Token).ConfigureAwait(false); // Update room settings. diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs index 3a586874fe..378096c7fb 100644 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs @@ -47,6 +47,8 @@ namespace osu.Game.Online.Spectator private readonly BindableList playingUsers = new BindableList(); + private readonly Dictionary playingUserStates = new Dictionary(); + [CanBeNull] private IBeatmap currentBeatmap; @@ -69,7 +71,7 @@ namespace osu.Game.Online.Spectator public event Action OnNewFrames; /// - /// Called whenever a user starts a play session. + /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session. /// public event Action OnUserBeganPlaying; @@ -123,7 +125,11 @@ namespace osu.Game.Online.Spectator } else { - playingUsers.Clear(); + lock (userLock) + { + playingUsers.Clear(); + playingUserStates.Clear(); + } } }, true); } @@ -131,8 +137,13 @@ namespace osu.Game.Online.Spectator Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) { - if (!playingUsers.Contains(userId)) - playingUsers.Add(userId); + lock (userLock) + { + if (!playingUsers.Contains(userId)) + playingUsers.Add(userId); + + playingUserStates[userId] = state; + } OnUserBeganPlaying?.Invoke(userId, state); @@ -141,7 +152,11 @@ namespace osu.Game.Online.Spectator Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) { - playingUsers.Remove(userId); + lock (userLock) + { + playingUsers.Remove(userId); + playingUserStates.Remove(userId); + } OnUserFinishedPlaying?.Invoke(userId, state); @@ -268,5 +283,37 @@ namespace osu.Game.Online.Spectator lastSendTime = Time.Current; } + + /// + /// Attempts to retrieve the for a currently-playing user. + /// + /// The user. + /// The current for the user, if they're playing. null if the user is not playing. + /// true if successful (the user is playing), false otherwise. + public bool TryGetPlayingUserState(int userId, out SpectatorState state) + { + lock (userLock) + return playingUserStates.TryGetValue(userId, out state); + } + + /// + /// Bind an action to with the option of running the bound action once immediately. + /// + /// The action to perform when a user begins playing. + /// Whether the action provided in should be run once immediately for all users currently playing. + public void BindUserBeganPlaying(Action callback, bool runOnceImmediately = false) + { + // The lock is taken before the event is subscribed to to prevent doubling of events. + lock (userLock) + { + OnUserBeganPlaying += callback; + + if (!runOnceImmediately) + return; + + foreach (var (userId, state) in playingUserStates) + callback(userId, state); + } + } } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 9af9a34b46..865ac7c34b 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -52,6 +52,7 @@ using LogLevel = osu.Framework.Logging.LogLevel; using osu.Game.Database; using osu.Game.IO; using osu.Game.Localisation; +using osu.Game.Skinning.Editor; namespace osu.Game { @@ -80,6 +81,8 @@ namespace osu.Game private BeatmapSetOverlay beatmapSetOverlay; + private SkinEditorOverlay skinEditor; + [Cached] private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender(); @@ -604,6 +607,8 @@ namespace osu.Game screenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) { RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Children = new Drawable[] { receptor = new BackButton.Receptor(), @@ -692,6 +697,7 @@ namespace osu.Game var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add); loadComponentSingleFile(new LoginOverlay { @@ -975,6 +981,8 @@ namespace osu.Game protected virtual void ScreenChanged(IScreen current, IScreen newScreen) { + skinEditor.Reset(); + switch (newScreen) { case IntroScreen intro: diff --git a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs index 31c1439c8f..1ffcf9722a 100644 --- a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs +++ b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs @@ -13,6 +13,8 @@ using osuTK.Graphics; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Users; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.BeatmapSet { @@ -50,7 +52,7 @@ namespace osu.Game.Overlays.BeatmapSet fields.Children = new Drawable[] { - new Field("mapped by", BeatmapSet.Metadata.Author.Username, OsuFont.GetFont(weight: FontWeight.Regular, italics: true)), + new Field("mapped by", BeatmapSet.Metadata.Author, OsuFont.GetFont(weight: FontWeight.Regular, italics: true)), new Field("submitted", online.Submitted, OsuFont.GetFont(weight: FontWeight.Bold)) { Margin = new MarginPadding { Top = 5 }, @@ -146,6 +148,25 @@ namespace osu.Game.Overlays.BeatmapSet } }; } + + public Field(string first, User second, FontUsage secondFont) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + + Children = new[] + { + new LinkFlowContainer(s => + { + s.Font = OsuFont.GetFont(size: 11); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.AddText($"{first} "); + d.AddUserLink(second, s => s.Font = secondFont.With(size: 11)); + }), + }; + } } } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index cffff86a64..6d27342049 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -47,52 +47,44 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { FillFlowContainer textSprites; - AddRangeInternal(new Drawable[] + AddInternal(shakeContainer = new ShakeContainer { - shakeContainer = new ShakeContainer + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Child = button = new HeaderButton { RelativeSizeAxes = Axes.Both }, + }); + + button.AddRange(new Drawable[] + { + new Container { - Depth = -1, + Padding = new MarginPadding { Horizontal = 10 }, RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, Children = new Drawable[] { - button = new HeaderButton { RelativeSizeAxes = Axes.Both }, - new Container + textSprites = new FillFlowContainer { - // cannot nest inside here due to the structure of button (putting things in its own content). - // requires framework fix. - Padding = new MarginPadding { Horizontal = 10 }, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - textSprites = new FillFlowContainer - { - Depth = -1, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - AutoSizeDuration = 500, - AutoSizeEasing = Easing.OutQuint, - Direction = FillDirection.Vertical, - }, - new SpriteIcon - { - Depth = -1, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Icon = FontAwesome.Solid.Download, - Size = new Vector2(18), - }, - } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Direction = FillDirection.Vertical, }, - new DownloadProgressBar(BeatmapSet.Value) + new SpriteIcon { - Depth = -2, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Icon = FontAwesome.Solid.Download, + Size = new Vector2(18), }, - }, + } + }, + new DownloadProgressBar(BeatmapSet.Value) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, }, }); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index b598b7d97f..aff48919b4 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -60,7 +60,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores var scoreInfos = value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToList(); var topScore = scoreInfos.First(); - scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked); + scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status.GrantsPerformancePoints() == true); scoreTable.Show(); var userScore = value.UserScore; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 5cb834b510..262f321598 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -111,7 +111,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores accuracyColumn.Text = value.DisplayAccuracy; maxComboColumn.Text = $@"{value.MaxCombo:N0}x"; - ppColumn.Alpha = value.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked ? 1 : 0; + + ppColumn.Alpha = value.Beatmap?.Status.GrantsPerformancePoints() == true ? 1 : 0; ppColumn.Text = $@"{value.PP:N0}"; statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 26b8632d7f..754b260bf0 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -245,15 +245,21 @@ namespace osu.Game.Overlays.Mods }, } }, - ModSettingsContainer = new ModSettingsContainer + new Container { RelativeSizeAxes = Axes.Both, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Width = 0.3f, - Alpha = 0, Padding = new MarginPadding(30), - SelectedMods = { BindTarget = SelectedMods }, + Width = 0.3f, + Children = new Drawable[] + { + ModSettingsContainer = new ModSettingsContainer + { + Alpha = 0, + SelectedMods = { BindTarget = SelectedMods }, + }, + } }, } }, diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 780d7ea986..fe9c710bcc 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -20,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps private readonly BeatmapSetType type; public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, string headerText) - : base(user, headerText, "", CounterVisibilityState.AlwaysVisible) + : base(user, headerText) { this.type = type; ItemsPerPage = 6; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index e5bb1f8008..eeb14e5e4f 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { public PaginatedMostPlayedBeatmapContainer(Bindable user) - : base(user, "Most Played Beatmaps", "No records. :(", CounterVisibilityState.AlwaysVisible) + : base(user, "Most Played Beatmaps") { ItemsPerPage = 5; } diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index 6e2b9873cf..4fbb7fc7d7 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -20,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections { new PlayHistorySubsection(User), new PaginatedMostPlayedBeatmapContainer(User), - new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)", CounterVisibilityState.VisibleWhenZero), + new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)"), new ReplaysSubsection(User) }; } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index 51e5622f68..e237b43b2e 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -38,8 +38,8 @@ namespace osu.Game.Overlays.Profile.Sections private OsuSpriteText missing; private readonly string missingText; - protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) - : base(user, headerText, counterVisibilityState) + protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "") + : base(user, headerText, CounterVisibilityState.AlwaysVisible) { this.missingText = missingText; } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 53f6d375ca..720cd4a3db 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -18,8 +18,8 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly ScoreType type; - public PaginatedScoreContainer(ScoreType type, Bindable user, string headerText, CounterVisibilityState counterVisibilityState, string missingText = "") - : base(user, headerText, missingText, counterVisibilityState) + public PaginatedScoreContainer(ScoreType type, Bindable user, string headerText) + : base(user, headerText) { this.type = type; @@ -36,9 +36,15 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { switch (type) { + case ScoreType.Best: + return user.ScoresBestCount; + case ScoreType.Firsts: return user.ScoresFirstCount; + case ScoreType.Recent: + return user.ScoresRecentCount; + default: return 0; } @@ -50,9 +56,6 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks drawableItemIndex = 0; base.OnItemsReceived(items); - - if (type == ScoreType.Recent) - SetCount(items.Count); } protected override APIRequest> CreateRequest() => diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index e41e414893..33f7c2f71a 100644 --- a/osu.Game/Overlays/Profile/Sections/RanksSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RanksSection.cs @@ -16,8 +16,8 @@ namespace osu.Game.Overlays.Profile.Sections { Children = new[] { - new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance", CounterVisibilityState.AlwaysHidden, "No performance records. :("), - new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks", CounterVisibilityState.AlwaysVisible) + new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance"), + new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks") }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 4d5c2e06eb..937bcc8abf 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -141,7 +141,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingSettings.ForEach(s => bindPreviewEvent(s.Current)); - windowModeDropdown.Current.ValueChanged += _ => updateResolutionDropdown(); + windowModeDropdown.Current.BindValueChanged(mode => + { + updateResolutionDropdown(); + + const string not_fullscreen_note = "Running without fullscreen mode will increase your input latency!"; + + windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? not_fullscreen_note : string.Empty; + }, true); windowModes.BindCollectionChanged((sender, args) => { diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 8773e6763c..70225ff6b8 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -13,6 +13,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { protected override string Header => "Renderer"; + private SettingsEnumDropdown frameLimiterDropdown; + [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, OsuConfigManager osuConfig) { @@ -20,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Children = new Drawable[] { // TODO: this needs to be a custom dropdown at some point - new SettingsEnumDropdown + frameLimiterDropdown = new SettingsEnumDropdown { LabelText = "Frame limiter", Current = config.GetBindable(FrameworkSetting.FrameSync) @@ -37,5 +39,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics }, }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + frameLimiterDropdown.Current.BindValueChanged(limit => + { + const string unlimited_frames_note = "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. \"2x refresh rate\" is recommended."; + + frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? unlimited_frames_note : string.Empty; + }, true); + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs index f61742093c..412889d210 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs @@ -129,9 +129,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input rotation.BindTo(handler.Rotation); rotation.BindValueChanged(val => { + tabletContainer.RotateTo(-val.NewValue, 800, Easing.OutQuint); usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint) .OnComplete(_ => checkBounds()); // required as we are using SSDQ. - }); + }, true); tablet.BindTo(handler.Tablet); tablet.BindValueChanged(_ => Scheduler.AddOnce(updateTabletDetails)); @@ -183,8 +184,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!(tablet.Value?.Size is Vector2 size)) return; - float fitX = size.X / (DrawWidth - Padding.Left - Padding.Right); - float fitY = size.Y / DrawHeight; + float maxDimension = size.LengthFast; + + float fitX = maxDimension / (DrawWidth - Padding.Left - Padding.Right); + float fitY = maxDimension / DrawHeight; float adjust = MathF.Max(fitX, fitY); diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 95e2e9da30..5f703ed5a4 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -2,8 +2,11 @@ // 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.Configuration; +using osu.Game.Online.API; +using osu.Game.Users; namespace osu.Game.Overlays.Settings.Sections.UserInterface { @@ -11,9 +14,15 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { protected override string Header => "Main Menu"; + private IBindable user; + + private SettingsEnumDropdown backgroundSourceDropdown; + [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, IAPIProvider api) { + user = api.LocalUser.GetBoundCopy(); + Children = new Drawable[] { new SettingsCheckbox @@ -31,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface LabelText = "Intro sequence", Current = config.GetBindable(OsuSetting.IntroSequence), }, - new SettingsEnumDropdown + backgroundSourceDropdown = new SettingsEnumDropdown { LabelText = "Background source", Current = config.GetBindable(OsuSetting.MenuBackgroundSource), @@ -43,5 +52,17 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + user.BindValueChanged(u => + { + const string not_supporter_note = "Changes to this setting will only apply with an active osu!supporter tag."; + + backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? not_supporter_note : string.Empty; + }, true); + } } } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 0bd9750b0b..86a836d29b 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -18,7 +18,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { @@ -36,10 +36,15 @@ namespace osu.Game.Overlays.Settings private SpriteText labelText; + private OsuTextFlowContainer warningText; + public bool ShowsDefaultIndicator = true; public string TooltipText { get; set; } + [Resolved] + private OsuColour colours { get; set; } + public virtual LocalisableString LabelText { get => labelText?.Text ?? string.Empty; @@ -57,6 +62,31 @@ namespace osu.Game.Overlays.Settings } } + /// + /// Text to be displayed at the bottom of this . + /// Generally used to recommend the user change their setting as the current one is considered sub-optimal. + /// + public string WarningText + { + set + { + if (warningText == null) + { + // construct lazily for cases where the label is not needed (may be provided by the Control). + FlowContent.Add(warningText = new OsuTextFlowContainer + { + Colour = colours.Yellow, + Margin = new MarginPadding { Bottom = 5 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }); + } + + warningText.Alpha = string.IsNullOrWhiteSpace(value) ? 0 : 1; + warningText.Text = value; + } + } + public virtual Bindable Current { get => controlWithCurrent.Current; @@ -92,7 +122,10 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, - Child = Control = CreateControl() + Children = new[] + { + Control = CreateControl(), + }, }, }; @@ -141,6 +174,7 @@ namespace osu.Game.Overlays.Settings { RelativeSizeAxes = Axes.Y; Width = SettingsPanel.CONTENT_MARGINS; + Padding = new MarginPadding { Vertical = 1.5f }; Alpha = 0f; } @@ -163,7 +197,7 @@ namespace osu.Game.Overlays.Settings Type = EdgeEffectType.Glow, Radius = 2, }, - Size = new Vector2(0.33f, 0.8f), + Width = 0.33f, Child = new Box { RelativeSizeAxes = Axes.Both }, }; } @@ -196,12 +230,6 @@ namespace osu.Game.Overlays.Settings UpdateState(); } - public void SetButtonColour(Color4 buttonColour) - { - this.buttonColour = buttonColour; - UpdateState(); - } - public void UpdateState() => Scheduler.AddOnce(updateState); private void updateState() diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index 905d5b44c6..eb235632e8 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -13,6 +14,8 @@ using osu.Framework.Input.Events; using osuTK.Input; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; namespace osu.Game.Overlays.Toolbar { @@ -20,6 +23,8 @@ namespace osu.Game.Overlays.Toolbar { protected Drawable ModeButtonLine { get; private set; } + private readonly Dictionary selectionSamples = new Dictionary(); + public ToolbarRulesetSelector() { RelativeSizeAxes = Axes.Y; @@ -27,7 +32,7 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { AddRangeInternal(new[] { @@ -54,6 +59,9 @@ namespace osu.Game.Overlays.Toolbar } } }); + + foreach (var ruleset in Rulesets.AvailableRulesets) + selectionSamples[ruleset.ShortName] = audio.Samples.Get($"UI/ruleset-select-{ruleset.ShortName}"); } protected override void LoadComplete() @@ -72,6 +80,10 @@ namespace osu.Game.Overlays.Toolbar if (SelectedTab != null) { ModeButtonLine.MoveToX(SelectedTab.DrawPosition.X, !hasInitialPosition ? 0 : 200, Easing.OutQuint); + + if (hasInitialPosition) + selectionSamples[SelectedTab.Value.ShortName]?.Play(); + hasInitialPosition = true; } }); diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index f9bced7beb..2f7b7b0ab8 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -16,9 +16,22 @@ namespace osu.Game.Rulesets.Edit { private readonly List checks = new List { - new CheckBackground(), + // Resources + new CheckBackgroundPresence(), + new CheckBackgroundQuality(), + + // Audio + new CheckAudioPresence(), + new CheckAudioQuality(), + + // Compose + new CheckUnsnappedObjects(), + new CheckConcurrentObjects() }; - public IEnumerable Run(IBeatmap beatmap) => checks.SelectMany(check => check.Run(beatmap)); + public IEnumerable Run(IBeatmap playableBeatmap, WorkingBeatmap workingBeatmap) + { + return checks.SelectMany(check => check.Run(playableBeatmap, workingBeatmap)); + } } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs new file mode 100644 index 0000000000..2d572a521e --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckAudioPresence : CheckFilePresence + { + protected override CheckCategory Category => CheckCategory.Audio; + protected override string TypeOfFile => "audio"; + protected override string GetFilename(IBeatmap playableBeatmap) => playableBeatmap.Metadata?.AudioFile; + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs new file mode 100644 index 0000000000..c1074d7c74 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckAudioQuality : ICheck + { + // This is a requirement as stated in the Ranking Criteria. + // See https://osu.ppy.sh/wiki/en/Ranking_Criteria#rules.4 + private const int max_bitrate = 192; + + // "A song's audio file /.../ must be of reasonable quality. Try to find the highest quality source file available" + // There not existing a version with a bitrate of 128 kbps or higher is extremely rare. + private const int min_bitrate = 128; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Too high or low audio bitrate"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooHighBitrate(this), + new IssueTemplateTooLowBitrate(this), + new IssueTemplateNoBitrate(this) + }; + + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap) + { + var audioFile = playableBeatmap.Metadata?.AudioFile; + if (audioFile == null) + yield break; + + var track = workingBeatmap.Track; + + if (track?.Bitrate == null || track.Bitrate.Value == 0) + yield return new IssueTemplateNoBitrate(this).Create(); + else if (track.Bitrate.Value > max_bitrate) + yield return new IssueTemplateTooHighBitrate(this).Create(track.Bitrate.Value); + else if (track.Bitrate.Value < min_bitrate) + yield return new IssueTemplateTooLowBitrate(this).Create(track.Bitrate.Value); + } + + public class IssueTemplateTooHighBitrate : IssueTemplate + { + public IssueTemplateTooHighBitrate(ICheck check) + : base(check, IssueType.Problem, "The audio bitrate ({0} kbps) exceeds {1} kbps.") + { + } + + public Issue Create(int bitrate) => new Issue(this, bitrate, max_bitrate); + } + + public class IssueTemplateTooLowBitrate : IssueTemplate + { + public IssueTemplateTooLowBitrate(ICheck check) + : base(check, IssueType.Problem, "The audio bitrate ({0} kbps) is lower than {1} kbps.") + { + } + + public Issue Create(int bitrate) => new Issue(this, bitrate, min_bitrate); + } + + public class IssueTemplateNoBitrate : IssueTemplate + { + public IssueTemplateNoBitrate(ICheck check) + : base(check, IssueType.Error, "The audio bitrate could not be retrieved.") + { + } + + public Issue Create() => new Issue(this); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackground.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackground.cs deleted file mode 100644 index 93da42425c..0000000000 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackground.cs +++ /dev/null @@ -1,61 +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 System.Linq; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Edit.Checks.Components; - -namespace osu.Game.Rulesets.Edit.Checks -{ - public class CheckBackground : ICheck - { - public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Missing background"); - - public IEnumerable PossibleTemplates => new IssueTemplate[] - { - new IssueTemplateNoneSet(this), - new IssueTemplateDoesNotExist(this) - }; - - public IEnumerable Run(IBeatmap beatmap) - { - if (beatmap.Metadata.BackgroundFile == null) - { - yield return new IssueTemplateNoneSet(this).Create(); - - yield break; - } - - // If the background is set, also make sure it still exists. - - var set = beatmap.BeatmapInfo.BeatmapSet; - var file = set.Files.FirstOrDefault(f => f.Filename == beatmap.Metadata.BackgroundFile); - - if (file != null) - yield break; - - yield return new IssueTemplateDoesNotExist(this).Create(beatmap.Metadata.BackgroundFile); - } - - public class IssueTemplateNoneSet : IssueTemplate - { - public IssueTemplateNoneSet(ICheck check) - : base(check, IssueType.Problem, "No background has been set.") - { - } - - public Issue Create() => new Issue(this); - } - - public class IssueTemplateDoesNotExist : IssueTemplate - { - public IssueTemplateDoesNotExist(ICheck check) - : base(check, IssueType.Problem, "The background file \"{0}\" does not exist.") - { - } - - public Issue Create(string filename) => new Issue(this, filename); - } - } -} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs new file mode 100644 index 0000000000..233c708a25 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckBackgroundPresence : CheckFilePresence + { + protected override CheckCategory Category => CheckCategory.Resources; + protected override string TypeOfFile => "background"; + protected override string GetFilename(IBeatmap playableBeatmap) => playableBeatmap.Metadata?.BackgroundFile; + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs new file mode 100644 index 0000000000..59fee74023 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckBackgroundQuality : ICheck + { + // These are the requirements as stated in the Ranking Criteria. + // See https://osu.ppy.sh/wiki/en/Ranking_Criteria#rules.5 + private const int min_width = 160; + private const int max_width = 2560; + private const int min_height = 120; + private const int max_height = 1440; + private const double max_filesize_mb = 2.5d; + + // It's usually possible to find a higher resolution of the same image if lower than these. + private const int low_width = 960; + private const int low_height = 540; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Too high or low background resolution"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooHighResolution(this), + new IssueTemplateTooLowResolution(this), + new IssueTemplateTooUncompressed(this) + }; + + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap) + { + var backgroundFile = playableBeatmap.Metadata?.BackgroundFile; + if (backgroundFile == null) + yield break; + + var texture = workingBeatmap.Background; + if (texture == null) + yield break; + + if (texture.Width > max_width || texture.Height > max_height) + yield return new IssueTemplateTooHighResolution(this).Create(texture.Width, texture.Height); + + if (texture.Width < min_width || texture.Height < min_height) + yield return new IssueTemplateTooLowResolution(this).Create(texture.Width, texture.Height); + else if (texture.Width < low_width || texture.Height < low_height) + yield return new IssueTemplateLowResolution(this).Create(texture.Width, texture.Height); + + string storagePath = playableBeatmap.BeatmapInfo.BeatmapSet.GetPathForFile(backgroundFile); + double filesizeMb = workingBeatmap.GetStream(storagePath).Length / (1024d * 1024d); + + if (filesizeMb > max_filesize_mb) + yield return new IssueTemplateTooUncompressed(this).Create(filesizeMb); + } + + public class IssueTemplateTooHighResolution : IssueTemplate + { + public IssueTemplateTooHighResolution(ICheck check) + : base(check, IssueType.Problem, "The background resolution ({0} x {1}) exceeds {2} x {3}.") + { + } + + public Issue Create(double width, double height) => new Issue(this, width, height, max_width, max_height); + } + + public class IssueTemplateTooLowResolution : IssueTemplate + { + public IssueTemplateTooLowResolution(ICheck check) + : base(check, IssueType.Problem, "The background resolution ({0} x {1}) is lower than {2} x {3}.") + { + } + + public Issue Create(double width, double height) => new Issue(this, width, height, min_width, min_height); + } + + public class IssueTemplateLowResolution : IssueTemplate + { + public IssueTemplateLowResolution(ICheck check) + : base(check, IssueType.Warning, "The background resolution ({0} x {1}) is lower than {2} x {3}.") + { + } + + public Issue Create(double width, double height) => new Issue(this, width, height, low_width, low_height); + } + + public class IssueTemplateTooUncompressed : IssueTemplate + { + public IssueTemplateTooUncompressed(ICheck check) + : base(check, IssueType.Problem, "The background filesize ({0:0.##} MB) exceeds {1} MB.") + { + } + + public Issue Create(double actualMb) => new Issue(this, actualMb, max_filesize_mb); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs new file mode 100644 index 0000000000..ddebe2923a --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckConcurrentObjects : ICheck + { + // We guarantee that the objects are either treated as concurrent or unsnapped when near the same beat divisor. + private const double ms_leniency = CheckUnsnappedObjects.UNSNAP_MS_THRESHOLD; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Concurrent hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateConcurrentSame(this), + new IssueTemplateConcurrentDifferent(this) + }; + + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap) + { + for (int i = 0; i < playableBeatmap.HitObjects.Count - 1; ++i) + { + var hitobject = playableBeatmap.HitObjects[i]; + + for (int j = i + 1; j < playableBeatmap.HitObjects.Count; ++j) + { + var nextHitobject = playableBeatmap.HitObjects[j]; + + // Accounts for rulesets with hitobjects separated by columns, such as Mania. + // In these cases we only care about concurrent objects within the same column. + if ((hitobject as IHasColumn)?.Column != (nextHitobject as IHasColumn)?.Column) + continue; + + // Two hitobjects cannot be concurrent without also being concurrent with all objects in between. + // So if the next object is not concurrent, then we know no future objects will be either. + if (!areConcurrent(hitobject, nextHitobject)) + break; + + if (hitobject.GetType() == nextHitobject.GetType()) + yield return new IssueTemplateConcurrentSame(this).Create(hitobject, nextHitobject); + else + yield return new IssueTemplateConcurrentDifferent(this).Create(hitobject, nextHitobject); + } + } + } + + private bool areConcurrent(HitObject hitobject, HitObject nextHitobject) => nextHitobject.StartTime <= hitobject.GetEndTime() + ms_leniency; + + public abstract class IssueTemplateConcurrent : IssueTemplate + { + protected IssueTemplateConcurrent(ICheck check, string unformattedMessage) + : base(check, IssueType.Problem, unformattedMessage) + { + } + + public Issue Create(HitObject hitobject, HitObject nextHitobject) + { + var hitobjects = new List { hitobject, nextHitobject }; + return new Issue(hitobjects, this, hitobject.GetType().Name, nextHitobject.GetType().Name) + { + Time = nextHitobject.StartTime + }; + } + } + + public class IssueTemplateConcurrentSame : IssueTemplateConcurrent + { + public IssueTemplateConcurrentSame(ICheck check) + : base(check, "{0}s are concurrent here.") + { + } + } + + public class IssueTemplateConcurrentDifferent : IssueTemplateConcurrent + { + public IssueTemplateConcurrentDifferent(ICheck check) + : base(check, "{0} and {1} are concurrent here.") + { + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs new file mode 100644 index 0000000000..006fc57c04 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public abstract class CheckFilePresence : ICheck + { + protected abstract CheckCategory Category { get; } + protected abstract string TypeOfFile { get; } + protected abstract string GetFilename(IBeatmap playableBeatmap); + + public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateNoneSet(this), + new IssueTemplateDoesNotExist(this) + }; + + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap) + { + var filename = GetFilename(playableBeatmap); + + if (filename == null) + { + yield return new IssueTemplateNoneSet(this).Create(TypeOfFile); + + yield break; + } + + // If the file is set, also make sure it still exists. + var storagePath = playableBeatmap.BeatmapInfo.BeatmapSet.GetPathForFile(filename); + if (storagePath != null) + yield break; + + yield return new IssueTemplateDoesNotExist(this).Create(TypeOfFile, filename); + } + + public class IssueTemplateNoneSet : IssueTemplate + { + public IssueTemplateNoneSet(ICheck check) + : base(check, IssueType.Problem, "No {0} has been set.") + { + } + + public Issue Create(string typeOfFile) => new Issue(this, typeOfFile); + } + + public class IssueTemplateDoesNotExist : IssueTemplate + { + public IssueTemplateDoesNotExist(ICheck check) + : base(check, IssueType.Problem, "The {0} file \"{1}\" does not exist.") + { + } + + public Issue Create(string typeOfFile, string filename) => new Issue(this, typeOfFile, filename); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs new file mode 100644 index 0000000000..cdf3f05465 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckUnsnappedObjects : ICheck + { + public const double UNSNAP_MS_THRESHOLD = 2; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Timing, "Unsnapped hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateLargeUnsnap(this), + new IssueTemplateSmallUnsnap(this) + }; + + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap) + { + var controlPointInfo = playableBeatmap.ControlPointInfo; + + foreach (var hitobject in playableBeatmap.HitObjects) + { + double startUnsnap = hitobject.StartTime - controlPointInfo.GetClosestSnappedTime(hitobject.StartTime); + string startPostfix = hitobject is IHasDuration ? "start" : ""; + foreach (var issue in getUnsnapIssues(hitobject, startUnsnap, hitobject.StartTime, startPostfix)) + yield return issue; + + if (hitobject is IHasRepeats hasRepeats) + { + for (int repeatIndex = 0; repeatIndex < hasRepeats.RepeatCount; ++repeatIndex) + { + double spanDuration = hasRepeats.Duration / (hasRepeats.RepeatCount + 1); + double repeatTime = hitobject.StartTime + spanDuration * (repeatIndex + 1); + double repeatUnsnap = repeatTime - controlPointInfo.GetClosestSnappedTime(repeatTime); + foreach (var issue in getUnsnapIssues(hitobject, repeatUnsnap, repeatTime, "repeat")) + yield return issue; + } + } + + if (hitobject is IHasDuration hasDuration) + { + double endUnsnap = hasDuration.EndTime - controlPointInfo.GetClosestSnappedTime(hasDuration.EndTime); + foreach (var issue in getUnsnapIssues(hitobject, endUnsnap, hasDuration.EndTime, "end")) + yield return issue; + } + } + } + + private IEnumerable getUnsnapIssues(HitObject hitobject, double unsnap, double time, string postfix = "") + { + if (Math.Abs(unsnap) >= UNSNAP_MS_THRESHOLD) + yield return new IssueTemplateLargeUnsnap(this).Create(hitobject, unsnap, time, postfix); + else if (Math.Abs(unsnap) >= 1) + yield return new IssueTemplateSmallUnsnap(this).Create(hitobject, unsnap, time, postfix); + + // We don't care about unsnaps < 1 ms, as all object ends have these due to the way SV works. + } + + public abstract class IssueTemplateUnsnap : IssueTemplate + { + protected IssueTemplateUnsnap(ICheck check, IssueType type) + : base(check, type, "{0} is unsnapped by {1:0.##} ms.") + { + } + + public Issue Create(HitObject hitobject, double unsnap, double time, string postfix = "") + { + string objectName = hitobject.GetType().Name; + if (!string.IsNullOrEmpty(postfix)) + objectName += " " + postfix; + + return new Issue(hitobject, this, objectName, unsnap) { Time = time }; + } + } + + public class IssueTemplateLargeUnsnap : IssueTemplateUnsnap + { + public IssueTemplateLargeUnsnap(ICheck check) + : base(check, IssueType.Problem) + { + } + } + + public class IssueTemplateSmallUnsnap : IssueTemplateUnsnap + { + public IssueTemplateSmallUnsnap(ICheck check) + : base(check, IssueType.Negligible) + { + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs b/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs index f284240092..31a7583941 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs @@ -24,7 +24,8 @@ namespace osu.Game.Rulesets.Edit.Checks.Components /// /// Runs this check and returns any issues detected for the provided beatmap. /// - /// The beatmap to run the check on. - public IEnumerable Run(IBeatmap beatmap); + /// The playable beatmap of the beatmap to run the check on. + /// The working beatmap of the beatmap to run the check on. + public IEnumerable Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap); } } diff --git a/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs similarity index 94% rename from osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs rename to osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index c60d4c7834..62e2539c2a 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Edit /// /// A wrapper for a . Handles adding visual representations of s to the underlying . /// - internal class DrawableEditRulesetWrapper : CompositeDrawable + internal class DrawableEditorRulesetWrapper : CompositeDrawable where TObject : HitObject { public Playfield Playfield => drawableRuleset.Playfield; @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Edit [Resolved] private EditorBeatmap beatmap { get; set; } - public DrawableEditRulesetWrapper(DrawableRuleset drawableRuleset) + public DrawableEditorRulesetWrapper(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 736fc47dee..b47cf97a4d 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Edit protected ComposeBlueprintContainer BlueprintContainer { get; private set; } - private DrawableEditRulesetWrapper drawableRulesetWrapper; + private DrawableEditorRulesetWrapper drawableRulesetWrapper; protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Edit try { - drawableRulesetWrapper = new DrawableEditRulesetWrapper(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() })) + drawableRulesetWrapper = new DrawableEditorRulesetWrapper(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() })) { Clock = EditorClock, ProcessCustomClock = false @@ -182,8 +182,7 @@ namespace osu.Game.Rulesets.Edit /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// - protected virtual ComposeBlueprintContainer CreateBlueprintContainer() - => new ComposeBlueprintContainer(this); + protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); /// /// Construct a drawable ruleset for the provided ruleset. diff --git a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs index 61d8119635..b598176a35 100644 --- a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs @@ -12,6 +12,6 @@ namespace osu.Game.Rulesets.Edit /// public interface IBeatmapVerifier { - public IEnumerable Run(IBeatmap beatmap); + public IEnumerable Run(IBeatmap playableBeatmap, WorkingBeatmap workingBeatmap); } } diff --git a/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs b/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs index 75200e3027..7911cf874b 100644 --- a/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs @@ -3,12 +3,13 @@ using osu.Framework.Graphics.Primitives; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Edit { - public abstract class OverlaySelectionBlueprint : SelectionBlueprint + public abstract class OverlaySelectionBlueprint : SelectionBlueprint { /// /// The which this applies to. @@ -33,7 +34,5 @@ namespace osu.Game.Rulesets.Edit public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre; public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; - - public override Vector2 GetInstantDelta(Vector2 screenSpacePosition) => DrawableObject.Parent.ToLocalSpace(screenSpacePosition) - DrawableObject.Position; } } diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 6c1cd01796..4ad8c815fe 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Edit { @@ -128,8 +129,11 @@ namespace osu.Game.Rulesets.Edit case DoubleClickEvent _: return false; - case MouseButtonEvent _: - return true; + case MouseButtonEvent mouse: + // placement blueprints should generally block mouse from reaching underlying components (ie. performing clicks on interface buttons). + // for now, the one exception we want to allow is when using a non-main mouse button when shift is pressed, which is used to trigger object deletion + // while in placement mode. + return mouse.Button == MouseButton.Left || !mouse.ShiftPressed; default: return false; diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index 99cdca045b..55703a2cd3 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs @@ -3,44 +3,38 @@ using System; using osu.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Edit { /// - /// A blueprint placed above a adding editing functionality. + /// A blueprint placed above a displaying item adding editing functionality. /// - public abstract class SelectionBlueprint : CompositeDrawable, IStateful + public abstract class SelectionBlueprint : CompositeDrawable, IStateful { - public readonly HitObject HitObject; + public readonly T Item; /// - /// Invoked when this has been selected. + /// Invoked when this has been selected. /// - public event Action Selected; + public event Action> Selected; /// - /// Invoked when this has been deselected. + /// Invoked when this has been deselected. /// - public event Action Deselected; + public event Action> Deselected; public override bool HandlePositionalInput => ShouldBeAlive; public override bool RemoveWhenNotAlive => false; - [Resolved(CanBeNull = true)] - private HitObjectComposer composer { get; set; } - - protected SelectionBlueprint(HitObject hitObject) + protected SelectionBlueprint(T item) { - HitObject = hitObject; + Item = item; RelativeSizeAxes = Axes.Both; AlwaysPresent = true; @@ -91,7 +85,7 @@ namespace osu.Game.Rulesets.Edit protected virtual void OnDeselected() { - // selection blueprints are AlwaysPresent while the related DrawableHitObject is visible + // selection blueprints are AlwaysPresent while the related item is visible // set the body piece's alpha directly to avoid arbitrarily rendering frame buffers etc. of children. foreach (var d in InternalChildren) d.Hide(); @@ -133,7 +127,7 @@ namespace osu.Game.Rulesets.Edit public virtual MenuItem[] ContextMenuItems => Array.Empty(); /// - /// The screen-space point that causes this to be selected. + /// The screen-space point that causes this to be selected via a drag. /// public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre; @@ -142,8 +136,6 @@ namespace osu.Game.Rulesets.Edit /// public virtual Quad SelectionQuad => ScreenSpaceDrawQuad; - public virtual Vector2 GetInstantDelta(Vector2 screenSpacePosition) => Parent.ToLocalSpace(screenSpacePosition) - Position; - /// /// Handle to perform a partial deletion when the user requests a quick delete (Shift+Right Click). /// diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs new file mode 100644 index 0000000000..0d344b5269 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.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 System; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModBarrelRoll : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + where TObject : HitObject + { + /// + /// The current angle of rotation being applied by this mod. + /// Generally should be used to apply inverse rotation to elements which should not be rotated. + /// + protected float CurrentRotation { get; private set; } + + [SettingSource("Roll speed", "Rotations per minute")] + public BindableNumber SpinSpeed { get; } = new BindableDouble(0.5) + { + MinValue = 0.02, + MaxValue = 12, + Precision = 0.01, + }; + + [SettingSource("Direction", "The direction of rotation")] + public Bindable Direction { get; } = new Bindable(RotationDirection.Clockwise); + + public override string Name => "Barrel Roll"; + public override string Acronym => "BR"; + public override string Description => "The whole playfield is on a wheel!"; + public override double ScoreMultiplier => 1; + + public override string SettingDescription => $"{SpinSpeed.Value} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; + + public void Update(Playfield playfield) + { + playfield.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + // scale the playfield to allow all hitobjects to stay within the visible region. + + var playfieldSize = drawableRuleset.Playfield.DrawSize; + var minSide = MathF.Min(playfieldSize.X, playfieldSize.Y); + var maxSide = MathF.Max(playfieldSize.X, playfieldSize.Y); + drawableRuleset.Playfield.Scale = new Vector2(minSide / maxSide); + } + } +} diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs new file mode 100644 index 0000000000..f1207ec188 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModClassic : Mod + { + public override string Name => "Classic"; + + public override string Acronym => "CL"; + + public override double ScoreMultiplier => 1; + + public override IconUsage? Icon => FontAwesome.Solid.History; + + public override string Description => "Feeling nostalgic?"; + + public override bool Ranked => false; + + public override ModType Type => ModType.Conversion; + } +} diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 669e4cecbe..86c733c392 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -4,27 +4,30 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Primitives; using osu.Framework.Threading; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Pooling; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; -using osu.Game.Configuration; using osu.Game.Rulesets.UI; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { [Cached(typeof(DrawableHitObject))] - public abstract class DrawableHitObject : SkinReloadableDrawable + public abstract class DrawableHitObject : PoolableDrawableWithLifetime { /// /// Invoked after this 's applied has had its defaults applied. @@ -39,7 +42,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// The currently represented by this . /// - public HitObject HitObject { get; private set; } + public HitObject HitObject => Entry?.HitObject; /// /// The parenting , if any. @@ -108,7 +111,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// The scoring result of this . /// - public JudgementResult Result { get; private set; } + public JudgementResult Result => Entry?.Result; /// /// The relative X position of this hit object for sample playback balance adjustment. @@ -124,8 +127,6 @@ namespace osu.Game.Rulesets.Objects.Drawables private readonly Bindable userPositionalHitSounds = new Bindable(); private readonly Bindable comboIndexBindable = new Bindable(); - public override bool RemoveWhenNotAlive => false; - public override bool RemoveCompletedTransforms => false; protected override bool RequiresChildrenUpdate => true; public override bool IsPresent => base.IsPresent || (State.Value == ArmedState.Idle && Clock?.CurrentTime >= LifetimeStart); @@ -140,17 +141,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public IBindable State => state; - /// - /// Whether is currently applied. - /// - private bool hasHitObjectApplied; - - /// - /// The controlling the lifetime of the currently-attached . - /// - [CanBeNull] - private HitObjectLifetimeEntry lifetimeEntry; - [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } @@ -164,28 +154,25 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// /// The to be initially applied to this . - /// If null, a hitobject is expected to be later applied via (or automatically via pooling). + /// If null, a hitobject is expected to be later applied via (or automatically via pooling). /// protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null) + : base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null) { - HitObject = initialHitObject; + if (Entry != null) + ensureEntryHasResult(); } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, ISkinSource skinSource) { config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds); // Explicit non-virtual function call. base.AddInternal(Samples = new PausableSkinnableSound()); - } - protected override void LoadAsyncComplete() - { - base.LoadAsyncComplete(); - - if (HitObject != null) - Apply(HitObject, lifetimeEntry); + CurrentSkin = skinSource; + CurrentSkin.SourceChanged += onSkinSourceChanged; } protected override void LoadComplete() @@ -198,37 +185,37 @@ namespace osu.Game.Rulesets.Objects.Drawables } /// - /// Applies a new to be represented by this . + /// Applies a hit object to be represented by this . /// - /// The to apply. - /// The controlling the lifetime of . + [Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")] public void Apply([NotNull] HitObject hitObject, [CanBeNull] HitObjectLifetimeEntry lifetimeEntry) { - free(); - - HitObject = hitObject ?? throw new InvalidOperationException($"Cannot apply a null {nameof(HitObject)}."); - - this.lifetimeEntry = lifetimeEntry; - if (lifetimeEntry != null) - { - // Transfer lifetime from the entry. - LifetimeStart = lifetimeEntry.LifetimeStart; - LifetimeEnd = lifetimeEntry.LifetimeEnd; - - // Copy any existing result from the entry (required for rewind / judgement revert). - Result = lifetimeEntry.Result; - } + Apply(lifetimeEntry); else + Apply(hitObject); + } + + /// + /// Applies a new to be represented by this . + /// A new is automatically created and applied to this . + /// + public void Apply([NotNull] HitObject hitObject) + { + if (hitObject == null) + throw new ArgumentNullException($"Cannot apply a null {nameof(HitObject)}."); + + Apply(new SyntheticHitObjectEntry(hitObject)); + } + + protected sealed override void OnApply(HitObjectLifetimeEntry entry) + { + // LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset. + // We override this with DHO's InitialLifetimeOffset for a non-pooled DHO. + if (entry is SyntheticHitObjectEntry) LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; - // Ensure this DHO has a result. - Result ??= CreateResult(HitObject.CreateJudgement()) - ?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - - // Copy back the result to the entry for potential future retrieval. - if (lifetimeEntry != null) - lifetimeEntry.Result = Result; + ensureEntryHasResult(); foreach (var h in HitObject.NestedHitObjects) { @@ -277,18 +264,10 @@ namespace osu.Game.Rulesets.Objects.Drawables else updateState(ArmedState.Idle, true); } - - hasHitObjectApplied = true; } - /// - /// Removes the currently applied - /// - private void free() + protected sealed override void OnFree(HitObjectLifetimeEntry entry) { - if (!hasHitObjectApplied) - return; - StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); if (HitObject is IHasComboInformation combo) comboIndexBindable.UnbindFrom(combo.ComboIndexBindable); @@ -319,25 +298,9 @@ namespace osu.Game.Rulesets.Objects.Drawables OnFree(); - HitObject = null; ParentHitObject = null; - Result = null; - lifetimeEntry = null; clearExistingStateTransforms(); - - hasHitObjectApplied = false; - } - - protected sealed override void FreeAfterUse() - { - base.FreeAfterUse(); - - // Freeing while not in a pool would cause the DHO to not be usable elsewhere in the hierarchy without being re-applied. - if (!IsInPool) - return; - - free(); } /// @@ -385,7 +348,9 @@ namespace osu.Game.Rulesets.Objects.Drawables private void onDefaultsApplied(HitObject hitObject) { - Apply(hitObject, lifetimeEntry); + Debug.Assert(Entry != null); + Apply(Entry); + DefaultsApplied?.Invoke(this); } @@ -465,9 +430,14 @@ namespace osu.Game.Rulesets.Objects.Drawables base.ClearTransformsAfter(double.MinValue, true); } + /// + /// Reapplies the current . + /// + protected void RefreshStateTransforms() => updateState(State.Value, true); + /// /// Apply (generally fade-in) transforms leading into the start time. - /// The local drawable hierarchy is recursively delayed to for convenience. + /// The local drawable hierarchy is recursively delayed to for convenience. /// /// By default this will fade in the object from zero with no duration. /// @@ -521,17 +491,19 @@ namespace osu.Game.Rulesets.Objects.Drawables #endregion - protected sealed override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); + #region Skinning + protected ISkinSource CurrentSkin { get; private set; } + + private void onSkinSourceChanged() => Scheduler.AddOnce(() => + { UpdateComboColour(); - ApplySkin(skin, allowFallback); + ApplySkin(CurrentSkin, true); if (IsLoaded) updateState(State.Value, true); - } + }); protected void UpdateComboColour() { @@ -601,6 +573,8 @@ namespace osu.Game.Rulesets.Objects.Drawables Samples.Stop(); } + #endregion + protected override void Update() { base.Update(); @@ -638,30 +612,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action); - public override double LifetimeStart - { - get => base.LifetimeStart; - set => setLifetime(value, LifetimeEnd); - } - - public override double LifetimeEnd - { - get => base.LifetimeEnd; - set => setLifetime(LifetimeStart, value); - } - - private void setLifetime(double lifetimeStart, double lifetimeEnd) - { - base.LifetimeStart = lifetimeStart; - base.LifetimeEnd = lifetimeEnd; - - if (lifetimeEntry != null) - { - lifetimeEntry.LifetimeStart = lifetimeStart; - lifetimeEntry.LifetimeEnd = lifetimeEnd; - } - } - /// /// A safe offset prior to the start time of at which this may begin displaying contents. /// By default, s are assumed to display their contents within 10 seconds prior to the start time of . @@ -669,7 +619,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// This is only used as an optimisation to delay the initial update of this and may be tuned more aggressively if required. /// It is indirectly used to decide the automatic transform offset provided to . - /// A more accurate should be set for further optimisation (in , for example). + /// A more accurate should be set for further optimisation (in , for example). /// /// Only has an effect if this is not being pooled. /// For pooled s, use instead. @@ -783,12 +733,21 @@ namespace osu.Game.Rulesets.Objects.Drawables /// The that provides the scoring information. protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); + private void ensureEntryHasResult() + { + Debug.Assert(Entry != null); + Entry.Result ??= CreateResult(HitObject.CreateJudgement()) + ?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (HitObject != null) HitObject.DefaultsApplied -= onDefaultsApplied; + + CurrentSkin.SourceChanged -= onSkinSourceChanged; } } diff --git a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs index 1954d7e6d2..0d1eb68f07 100644 --- a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs +++ b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs @@ -38,40 +38,23 @@ namespace osu.Game.Rulesets.Objects startTimeBindable.BindValueChanged(onStartTimeChanged, true); } - // The lifetime start, as set by the hitobject. + // The lifetime, as set by the hitobject. private double realLifetimeStart = double.MinValue; - - /// - /// The time at which the should become alive. - /// - public new double LifetimeStart - { - get => realLifetimeStart; - set => setLifetime(realLifetimeStart = value, LifetimeEnd); - } - - // The lifetime end, as set by the hitobject. private double realLifetimeEnd = double.MaxValue; - /// - /// The time at which the should become dead. - /// - public new double LifetimeEnd + // This method is called even if `start == LifetimeStart` when `KeepAlive` is true (necessary to update `realLifetimeStart`). + protected override void SetLifetimeStart(double start) { - get => realLifetimeEnd; - set => setLifetime(LifetimeStart, realLifetimeEnd = value); + realLifetimeStart = start; + if (!keepAlive) + base.SetLifetimeStart(start); } - private void setLifetime(double start, double end) + protected override void SetLifetimeEnd(double end) { - if (keepAlive) - { - start = double.MinValue; - end = double.MaxValue; - } - - base.LifetimeStart = start; - base.LifetimeEnd = end; + realLifetimeEnd = end; + if (!keepAlive) + base.SetLifetimeEnd(end); } private bool keepAlive; @@ -87,7 +70,10 @@ namespace osu.Game.Rulesets.Objects return; keepAlive = value; - setLifetime(realLifetimeStart, realLifetimeEnd); + if (keepAlive) + SetLifetime(double.MinValue, double.MaxValue); + else + SetLifetime(realLifetimeStart, realLifetimeEnd); } } @@ -98,12 +84,12 @@ namespace osu.Game.Rulesets.Objects /// /// This is only used as an optimisation to delay the initial update of the and may be tuned more aggressively if required. /// It is indirectly used to decide the automatic transform offset provided to . - /// A more accurate should be set for further optimisation (in , for example). + /// A more accurate should be set for further optimisation (in , for example). /// protected virtual double InitialLifetimeOffset => 10000; /// - /// Resets according to the change in start time of the . + /// Resets according to the change in start time of the . /// private void onStartTimeChanged(ValueChangedEvent startTime) => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; } diff --git a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs new file mode 100644 index 0000000000..93e476be76 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System.Diagnostics; +using osu.Framework.Graphics.Performance; +using osu.Framework.Graphics.Pooling; + +namespace osu.Game.Rulesets.Objects.Pooling +{ + /// + /// A that is controlled by to implement drawable pooling and replay rewinding. + /// + /// The type storing state and controlling this drawable. + public abstract class PoolableDrawableWithLifetime : PoolableDrawable where TEntry : LifetimeEntry + { + /// + /// The entry holding essential state of this . + /// + protected TEntry? Entry { get; private set; } + + /// + /// Whether is applied to this . + /// When an initial entry is specified in the constructor, is set but not applied until loading is completed. + /// + protected bool HasEntryApplied { get; private set; } + + public override double LifetimeStart + { + get => base.LifetimeStart; + set => setLifetime(value, LifetimeEnd); + } + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set => setLifetime(LifetimeStart, value); + } + + public override bool RemoveWhenNotAlive => false; + public override bool RemoveCompletedTransforms => false; + + protected PoolableDrawableWithLifetime(TEntry? initialEntry = null) + { + Entry = initialEntry; + } + + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + + // Apply the initial entry given in the constructor. + if (Entry != null && !HasEntryApplied) + Apply(Entry); + } + + /// + /// Applies a new entry to be represented by this drawable. + /// If there is an existing entry applied, the entry will be replaced. + /// + public void Apply(TEntry entry) + { + if (HasEntryApplied) + free(); + + setLifetime(entry.LifetimeStart, entry.LifetimeEnd); + Entry = entry; + + OnApply(entry); + + HasEntryApplied = true; + } + + protected sealed override void FreeAfterUse() + { + base.FreeAfterUse(); + + // We preserve the existing entry in case we want to move a non-pooled drawable between different parent drawables. + if (HasEntryApplied && IsInPool) + free(); + } + + /// + /// Invoked to apply a new entry to this drawable. + /// + protected virtual void OnApply(TEntry entry) + { + } + + /// + /// Invoked to revert application of the entry to this drawable. + /// + protected virtual void OnFree(TEntry entry) + { + } + + private void setLifetime(double start, double end) + { + base.LifetimeStart = start; + base.LifetimeEnd = end; + + if (Entry != null) + { + Entry.LifetimeStart = start; + Entry.LifetimeEnd = end; + } + } + + private void free() + { + Debug.Assert(Entry != null && HasEntryApplied); + + OnFree(Entry); + + Entry = null; + setLifetime(double.MaxValue, double.MaxValue); + + HasEntryApplied = false; + } + } +} diff --git a/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs b/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs new file mode 100644 index 0000000000..76f9eaf25a --- /dev/null +++ b/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.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.Objects.Drawables; + +namespace osu.Game.Rulesets.Objects +{ + /// + /// Created for a when only is given + /// to make sure a is always associated with a . + /// + internal class SyntheticHitObjectEntry : HitObjectLifetimeEntry + { + public SyntheticHitObjectEntry(HitObject hitObject) + : base(hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Types/IHasColumn.cs b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs similarity index 90% rename from osu.Game.Rulesets.Mania/Objects/Types/IHasColumn.cs rename to osu.Game/Rulesets/Objects/Types/IHasColumn.cs index 1ea3138828..dc07cfbb6a 100644 --- a/osu.Game.Rulesets.Mania/Objects/Types/IHasColumn.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Rulesets.Mania.Objects.Types +namespace osu.Game.Rulesets.Objects.Types { /// /// A type of hit object which lies in one of a number of predetermined columns. diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index 0f25a45177..bc8994bbe5 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using osu.Game.Input.Handlers; using osu.Game.Replays; @@ -97,7 +98,7 @@ namespace osu.Game.Rulesets.Replays { // TODO: This replay frame ordering should be enforced on the Replay type. // Currently, the ordering can be broken if the frames are added after this construction. - replay.Frames.Sort((x, y) => x.Time.CompareTo(y.Time)); + replay.Frames = replay.Frames.OrderBy(f => f.Time).ToList(); this.replay = replay; currentFrameIndex = -1; diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 0fb5c2f4b5..f32f70d4ba 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -252,7 +252,7 @@ namespace osu.Game.Rulesets.Scoring computedBaseScore += Judgement.ToNumericResult(pair.Key) * pair.Value; } - return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), scoreResultCounts); + return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), statistics); } /// @@ -266,7 +266,7 @@ namespace osu.Game.Rulesets.Scoring if (preferRolling && rollingMaxBaseScore != 0) return baseScore / rollingMaxBaseScore; - return maxBaseScore > 0 ? baseScore / maxBaseScore : 0; + return maxBaseScore > 0 ? baseScore / maxBaseScore : 1; } private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1; diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index ca27e6b21a..a2dade2627 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -85,6 +85,7 @@ namespace osu.Game.Rulesets.UI /// /// The beatmap. /// + [Cached(typeof(IBeatmap))] public readonly Beatmap Beatmap; public override IEnumerable Objects => Beatmap.HitObjects; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index d55005363c..17d3cf01a4 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -362,7 +362,7 @@ namespace osu.Game.Rulesets.UI lifetimeEntryMap[hitObject] = entry = CreateLifetimeEntry(hitObject); dho.ParentHitObject = parent; - dho.Apply(hitObject, entry); + dho.Apply(entry); }); } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index db7e51e833..f8dd6953ad 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -8,7 +8,6 @@ using System.Text; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.IO.Legacy; -using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays.Types; using SharpCompress.Compressors.LZMA; @@ -91,12 +90,14 @@ namespace osu.Game.Scoring.Legacy if (score.Replay != null) { - LegacyReplayFrame lastF = new LegacyReplayFrame(0, 0, 0, ReplayButtonState.None); + int lastTime = 0; foreach (var f in score.Replay.Frames.OfType().Select(f => f.ToLegacy(beatmap))) { - replayData.Append(FormattableString.Invariant($"{f.Time - lastF.Time}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); - lastF = f; + // Rounding because stable could only parse integral values + int time = (int)Math.Round(f.Time); + replayData.Append(FormattableString.Invariant($"{time - lastTime}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); + lastTime = time; } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 222f69b025..a6faaf6379 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -65,14 +65,21 @@ namespace osu.Game.Scoring { get { + var rulesetInstance = Ruleset?.CreateInstance(); + if (rulesetInstance == null) + return mods ?? Array.Empty(); + + Mod[] scoreMods = Array.Empty(); + if (mods != null) - return mods; + scoreMods = mods; + else if (localAPIMods != null) + scoreMods = apiMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - if (localAPIMods == null) - return Array.Empty(); + if (IsLegacyScore) + scoreMods = scoreMods.Append(rulesetInstance.GetAllMods().OfType().Single()).ToArray(); - var rulesetInstance = Ruleset.CreateInstance(); - return apiMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + return scoreMods; } set { diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index c7ee26c248..9d3b952ada 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -72,6 +72,16 @@ namespace osu.Game.Scoring } } + protected override void ExportModelTo(ScoreInfo model, Stream outputStream) + { + var file = model.Files.SingleOrDefault(); + if (file == null) + return; + + using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath)) + inputStream.CopyTo(outputStream); + } + protected override IEnumerable GetStableImportPaths(Storage storage) => storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) .Select(path => storage.GetFullPath(path)); diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs index 0cf7b83f3b..1f608d28fd 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class DrawableRadioButton : TriangleButton + public class DrawableRadioButton : OsuButton { /// /// Invoked when this has been selected. @@ -49,8 +49,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons selectedBackgroundColour = colours.BlueDark; selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); - Triangles.Alpha = 0; - Content.EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index c72fff5c91..c43561eaa7 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -15,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - internal class DrawableTernaryButton : TriangleButton + internal class DrawableTernaryButton : OsuButton { private Color4 defaultBackgroundColour; private Color4 defaultBubbleColour; @@ -43,8 +43,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons selectedBackgroundColour = colours.BlueDark; selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); - Triangles.Alpha = 0; - Content.EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index b1afbe0d61..361e98e0dd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -3,11 +3,9 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -16,46 +14,33 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// A container which provides a "blueprint" display of hitobjects. - /// Includes selection and manipulation support via a . + /// A container which provides a "blueprint" display of items. + /// Includes selection and manipulation support via a . /// - public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler { protected DragBox DragBox { get; private set; } - public Container SelectionBlueprints { get; private set; } + public Container> SelectionBlueprints { get; private set; } - protected SelectionHandler SelectionHandler { get; private set; } + protected SelectionHandler SelectionHandler { get; private set; } - protected readonly HitObjectComposer Composer; - - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } - - [Resolved] - protected EditorClock EditorClock { get; private set; } - - [Resolved] - protected EditorBeatmap Beatmap { get; private set; } - - private readonly BindableList selectedHitObjects = new BindableList(); - private readonly Dictionary blueprintMap = new Dictionary(); + private readonly Dictionary> blueprintMap = new Dictionary>(); [Resolved(canBeNull: true)] private IPositionSnapProvider snapProvider { get; set; } - protected BlueprintContainer(HitObjectComposer composer) - { - Composer = composer; + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + protected BlueprintContainer() + { RelativeSizeAxes = Axes.Both; } @@ -73,66 +58,28 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionHandler.CreateProxy(), DragBox.CreateProxy().With(p => p.Depth = float.MinValue) }); - - // For non-pooled rulesets, hitobjects are already present in the playfield which allows the blueprints to be loaded in the async context. - if (Composer != null) - { - foreach (var obj in Composer.HitObjects) - addBlueprintFor(obj.HitObject); - } - - selectedHitObjects.BindTo(Beatmap.SelectedHitObjects); - selectedHitObjects.CollectionChanged += (selectedObjects, args) => - { - switch (args.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var o in args.NewItems) - SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Select(); - break; - - case NotifyCollectionChangedAction.Remove: - foreach (var o in args.OldItems) - SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect(); - - break; - } - }; } - protected override void LoadComplete() - { - base.LoadComplete(); - - Beatmap.HitObjectAdded += addBlueprintFor; - Beatmap.HitObjectRemoved += removeBlueprintFor; - - if (Composer != null) - { - // For pooled rulesets, blueprints must be added for hitobjects already "current" as they would've not been "current" during the async load addition process above. - foreach (var obj in Composer.HitObjects) - addBlueprintFor(obj.HitObject); - - Composer.Playfield.HitObjectUsageBegan += addBlueprintFor; - Composer.Playfield.HitObjectUsageFinished += removeBlueprintFor; - } - } - - protected virtual Container CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; + protected virtual Container> CreateSelectionBlueprintContainer() => new Container> { RelativeSizeAxes = Axes.Both }; /// - /// Creates a which outlines s and handles movement of selections. + /// Creates a which outlines items and handles movement of selections. /// - protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); + protected abstract SelectionHandler CreateSelectionHandler(); /// - /// Creates a for a specific . + /// Creates a for a specific item. /// - /// The to create the overlay for. - protected virtual SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => null; + /// The item to create the overlay for. + protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); + /// + /// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to. + /// + protected virtual bool AllowDeselectionDuringDrag => true; + protected override bool OnMouseDown(MouseDownEvent e) { bool selectionPerformed = performMouseDownActions(e); @@ -143,7 +90,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return selectionPerformed || e.Button == MouseButton.Left; } - private SelectionBlueprint clickedBlueprint; + protected SelectionBlueprint ClickedBlueprint { get; private set; } protected override bool OnClick(ClickEvent e) { @@ -151,11 +98,11 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; // store for double-click handling - clickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); + 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(e) || clickedBlueprint != null) + // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the item and should not trigger deselection + if (endClickSelection(e) || ClickedBlueprint != null) return true; deselectAll(); @@ -168,10 +115,9 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; // 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) + if (ClickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != ClickedBlueprint) return false; - EditorClock?.SeekSmoothlyTo(clickedBlueprint.HitObject.StartTime); return true; } @@ -227,10 +173,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (isDraggingBlueprint) { - // handle positional change etc. - foreach (var obj in selectedHitObjects) - Beatmap.Update(obj); - + DragOperationCompleted(); changeHandler?.EndChange(); } @@ -238,6 +181,13 @@ namespace osu.Game.Screens.Edit.Compose.Components DragBox.Hide(); } + /// + /// Called whenever a drag operation completes, before any change transaction is committed. + /// + protected virtual void DragOperationCompleted() + { + } + protected override bool OnKeyDown(KeyDownEvent e) { switch (e.Key) @@ -258,7 +208,7 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (action.ActionType) { case PlatformActionType.SelectAll: - selectAll(); + SelectAll(); return true; } @@ -271,61 +221,58 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Blueprint Addition/Removal - private void addBlueprintFor(HitObject hitObject) + protected virtual void AddBlueprintFor(T item) { - if (hitObject is IBarLine) + if (blueprintMap.ContainsKey(item)) return; - if (blueprintMap.ContainsKey(hitObject)) - return; - - var blueprint = CreateBlueprintFor(hitObject); + var blueprint = CreateBlueprintFor(item); if (blueprint == null) return; - blueprintMap[hitObject] = blueprint; + blueprintMap[item] = blueprint; - blueprint.Selected += onBlueprintSelected; - blueprint.Deselected += onBlueprintDeselected; - - if (Beatmap.SelectedHitObjects.Contains(hitObject)) - blueprint.Select(); + blueprint.Selected += OnBlueprintSelected; + blueprint.Deselected += OnBlueprintDeselected; SelectionBlueprints.Add(blueprint); - OnBlueprintAdded(hitObject); + if (SelectionHandler.SelectedItems.Contains(item)) + blueprint.Select(); + + OnBlueprintAdded(blueprint.Item); } - private void removeBlueprintFor(HitObject hitObject) + protected void RemoveBlueprintFor(T item) { - if (!blueprintMap.Remove(hitObject, out var blueprint)) + if (!blueprintMap.Remove(item, out var blueprint)) return; blueprint.Deselect(); - blueprint.Selected -= onBlueprintSelected; - blueprint.Deselected -= onBlueprintDeselected; + blueprint.Selected -= OnBlueprintSelected; + blueprint.Deselected -= OnBlueprintDeselected; SelectionBlueprints.Remove(blueprint); if (movementBlueprints?.Contains(blueprint) == true) finishSelectionMovement(); - OnBlueprintRemoved(hitObject); + OnBlueprintRemoved(blueprint.Item); } /// - /// Called after a blueprint has been added. + /// Called after an item's blueprint has been added. /// - /// The for which the blueprint has been added. - protected virtual void OnBlueprintAdded(HitObject hitObject) + /// The item for which the blueprint has been added. + protected virtual void OnBlueprintAdded(T item) { } /// - /// Called after a blueprint has been removed. + /// Called after an item's blueprint has been removed. /// - /// The for which the blueprint has been removed. - protected virtual void OnBlueprintRemoved(HitObject hitObject) + /// The item for which the blueprint has been removed. + protected virtual void OnBlueprintRemoved(T item) { } @@ -347,7 +294,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { // Iterate from the top of the input stack (blueprints closest to the front of the screen first). // Priority is given to already-selected blueprints. - foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) { if (!blueprint.IsHovered) continue; @@ -371,7 +318,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { // Iterate from the top of the input stack (blueprints closest to the front of the screen first). // Priority is given to already-selected blueprints. - foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) { if (!blueprint.IsHovered) continue; @@ -404,8 +351,7 @@ namespace osu.Game.Screens.Edit.Compose.Components break; case SelectionState.Selected: - // if the editor is playing, we generally don't want to deselect objects even if outside the selection area. - if (!EditorClock.IsRunning && !isValidForSelection()) + if (AllowDeselectionDuringDrag && !isValidForSelection()) blueprint.Deselect(); break; } @@ -413,35 +359,29 @@ namespace osu.Game.Screens.Edit.Compose.Components } /// - /// Selects all s. + /// Selects all s. /// - private void selectAll() + protected virtual void SelectAll() { - Composer.Playfield.KeepAllAlive(); - // Scheduled to allow the change in lifetime to take place. Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select())); } /// - /// Deselects all selected s. + /// Deselects all selected s. /// private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); - private void onBlueprintSelected(SelectionBlueprint blueprint) + protected virtual void OnBlueprintSelected(SelectionBlueprint blueprint) { SelectionHandler.HandleSelected(blueprint); SelectionBlueprints.ChangeChildDepth(blueprint, 1); - - Composer.Playfield.SetKeepAlive(blueprint.HitObject, true); } - private void onBlueprintDeselected(SelectionBlueprint blueprint) + protected virtual void OnBlueprintDeselected(SelectionBlueprint blueprint) { SelectionBlueprints.ChangeChildDepth(blueprint, 0); SelectionHandler.HandleDeselected(blueprint); - - Composer.Playfield.SetKeepAlive(blueprint.HitObject, false); } #endregion @@ -449,7 +389,7 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Movement private Vector2[] movementBlueprintOriginalPositions; - private SelectionBlueprint[] movementBlueprints; + private SelectionBlueprint[] movementBlueprints; private bool isDraggingBlueprint; /// @@ -460,16 +400,23 @@ namespace osu.Game.Screens.Edit.Compose.Components if (!SelectionHandler.SelectedBlueprints.Any()) return; - // Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement + // Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement. // A special case is added for when a click selection occurred before the drag if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) return; - // Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject - movementBlueprints = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).ToArray(); + // Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item + movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray(); movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray(); } + /// + /// Apply sorting of selected blueprints before performing movement. Generally used to surface the "main" item to the beginning of the collection. + /// + /// The blueprints to be moved. + /// Sorted blueprints. + protected virtual IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints; + /// /// Moves the current selected blueprints. /// @@ -480,51 +427,50 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - if (snapProvider == null) - return true; - Debug.Assert(movementBlueprintOriginalPositions != null); Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; - // check for positional snap for every object in selection (for things like object-object snapping) - for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++) + if (snapProvider != null) { - var testPosition = movementBlueprintOriginalPositions[i] + distanceTravelled; + // check for positional snap for every object in selection (for things like object-object snapping) + for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++) + { + Vector2 originalPosition = movementBlueprintOriginalPositions[i]; + var testPosition = originalPosition + distanceTravelled; - var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition); + var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition); - if (positionalResult.ScreenSpacePosition == testPosition) continue; + if (positionalResult.ScreenSpacePosition == testPosition) continue; - // attempt to move the objects, and abort any time based snapping if we can. - if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], positionalResult.ScreenSpacePosition))) - return true; + var delta = positionalResult.ScreenSpacePosition - movementBlueprints[i].ScreenSpaceSelectionPoint; + + // attempt to move the objects, and abort any time based snapping if we can. + if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], delta))) + return true; + } } // if no positional snapping could be performed, try unrestricted snapping from the earliest - // hitobject in the selection. + // item in the selection. // The final movement position, relative to movementBlueprintOriginalPosition. Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled; // Retrieve a snapped position. - var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition); + var result = snapProvider?.SnapScreenSpacePositionToValidTime(movePosition); - // Move the hitobjects. - if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), result.ScreenSpacePosition))) - return true; - - if (result.Time.HasValue) + if (result == null) { - // Apply the start time at the newly snapped-to position - double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime; - - Beatmap.PerformOnSelection(obj => obj.StartTime += offset); + return SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), movePosition - movementBlueprints.First().ScreenSpaceSelectionPoint)); } - return true; + return ApplySnapResult(movementBlueprints, result); } + protected virtual bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) => + SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprints.First(), result.ScreenSpacePosition - blueprints.First().ScreenSpaceSelectionPoint)); + /// /// Finishes the current movement of selected blueprints. /// @@ -541,22 +487,5 @@ namespace osu.Game.Screens.Edit.Compose.Components } #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (Beatmap != null) - { - Beatmap.HitObjectAdded -= addBlueprintFor; - Beatmap.HitObjectRemoved -= removeBlueprintFor; - } - - if (Composer != null) - { - Composer.Playfield.HitObjectUsageBegan -= addBlueprintFor; - Composer.Playfield.HitObjectUsageFinished -= removeBlueprintFor; - } - } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index b0a6a091f0..6c174e563e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -27,12 +27,14 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A blueprint container generally displayed as an overlay to a ruleset's playfield. /// - public class ComposeBlueprintContainer : BlueprintContainer + public class ComposeBlueprintContainer : EditorBlueprintContainer { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; private readonly Container placementBlueprintContainer; + protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler; + private PlacementBlueprint currentPlacement; private InputManager inputManager; @@ -113,7 +115,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // convert to game space coordinates delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); - SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, firstBlueprint.ScreenSpaceSelectionPoint + delta)); + SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); } private void updatePlacementNewCombo() @@ -237,9 +239,9 @@ namespace osu.Game.Screens.Edit.Compose.Components updatePlacementPosition(); } - protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) + protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject item) { - var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == hitObject); + var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == item); if (drawable == null) return null; @@ -249,9 +251,9 @@ namespace osu.Game.Screens.Edit.Compose.Components public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; - protected override void OnBlueprintAdded(HitObject hitObject) + protected override void OnBlueprintAdded(HitObject item) { - base.OnBlueprintAdded(hitObject); + base.OnBlueprintAdded(item); refreshTool(); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs new file mode 100644 index 0000000000..db322faf65 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -0,0 +1,163 @@ +// 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.Collections.Specialized; +using System.Linq; +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.Rulesets.Edit; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class EditorBlueprintContainer : BlueprintContainer + { + [Resolved] + protected EditorClock EditorClock { get; private set; } + + [Resolved] + protected EditorBeatmap Beatmap { get; private set; } + + protected readonly HitObjectComposer Composer; + + private readonly BindableList selectedHitObjects = new BindableList(); + + protected EditorBlueprintContainer(HitObjectComposer composer) + { + Composer = composer; + } + + [BackgroundDependencyLoader] + private void load() + { + selectedHitObjects.BindTo(Beatmap.SelectedHitObjects); + selectedHitObjects.CollectionChanged += (selectedObjects, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var o in args.NewItems) + SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var o in args.OldItems) + SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); + + break; + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.HitObjectAdded += AddBlueprintFor; + Beatmap.HitObjectRemoved += RemoveBlueprintFor; + + if (Composer != null) + { + foreach (var obj in Composer.HitObjects) + AddBlueprintFor(obj.HitObject); + + Composer.Playfield.HitObjectUsageBegan += AddBlueprintFor; + Composer.Playfield.HitObjectUsageFinished += RemoveBlueprintFor; + } + } + + protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) + => blueprints.OrderBy(b => b.Item.StartTime); + + protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning; + + protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) + { + if (!base.ApplySnapResult(blueprints, result)) + return false; + + if (result.Time.HasValue) + { + // Apply the start time at the newly snapped-to position + double offset = result.Time.Value - blueprints.First().Item.StartTime; + + if (offset != 0) + Beatmap.PerformOnSelection(obj => obj.StartTime += offset); + } + + return true; + } + + protected override void AddBlueprintFor(HitObject item) + { + if (item is IBarLine) + return; + + base.AddBlueprintFor(item); + } + + protected override void DragOperationCompleted() + { + base.DragOperationCompleted(); + + // handle positional change etc. + foreach (var blueprint in SelectionBlueprints) + Beatmap.Update(blueprint.Item); + } + + protected override bool OnDoubleClick(DoubleClickEvent e) + { + if (!base.OnDoubleClick(e)) + return false; + + EditorClock?.SeekSmoothlyTo(ClickedBlueprint.Item.StartTime); + return true; + } + + protected override Container> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; + + protected override SelectionHandler CreateSelectionHandler() => new EditorSelectionHandler(); + + protected override void SelectAll() + { + Composer.Playfield.KeepAllAlive(); + + base.SelectAll(); + } + + protected override void OnBlueprintSelected(SelectionBlueprint blueprint) + { + base.OnBlueprintSelected(blueprint); + + Composer.Playfield.SetKeepAlive(blueprint.Item, true); + } + + protected override void OnBlueprintDeselected(SelectionBlueprint blueprint) + { + base.OnBlueprintDeselected(blueprint); + + Composer.Playfield.SetKeepAlive(blueprint.Item, false); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (Beatmap != null) + { + Beatmap.HitObjectAdded -= AddBlueprintFor; + Beatmap.HitObjectRemoved -= RemoveBlueprintFor; + } + + if (Composer != null) + { + Composer.Playfield.HitObjectUsageBegan -= AddBlueprintFor; + Composer.Playfield.HitObjectUsageFinished -= RemoveBlueprintFor; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs new file mode 100644 index 0000000000..6ab4ca8267 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -0,0 +1,183 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Audio; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class EditorSelectionHandler : SelectionHandler + { + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + createStateBindables(); + + // bring in updates from selection changes + EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); + + SelectedItems.BindTo(EditorBeatmap.SelectedHitObjects); + SelectedItems.CollectionChanged += (sender, args) => + { + Scheduler.AddOnce(UpdateTernaryStates); + }; + } + + protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); + + #region Selection State + + /// + /// The state of "new combo" for all selected hitobjects. + /// + public readonly Bindable SelectionNewComboState = new Bindable(); + + /// + /// The state of each sample type for all selected hitobjects. Keys match with constant specifications. + /// + public readonly Dictionary> SelectionSampleStates = new Dictionary>(); + + /// + /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) + /// + private void createStateBindables() + { + foreach (var sampleName in HitSampleInfo.AllAdditions) + { + var bindable = new Bindable + { + Description = sampleName.Replace("hit", string.Empty).Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + RemoveHitSample(sampleName); + break; + + case TernaryState.True: + AddHitSample(sampleName); + break; + } + }; + + SelectionSampleStates[sampleName] = bindable; + } + + // new combo + SelectionNewComboState.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + SetNewCombo(false); + break; + + case TernaryState.True: + SetNewCombo(true); + break; + } + }; + } + + /// + /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). + /// + protected virtual void UpdateTernaryStates() + { + SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); + + foreach (var (sampleName, bindable) in SelectionSampleStates) + { + bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.Any(s => s.Name == sampleName)); + } + } + + #endregion + + #region Ternary state changes + + /// + /// Adds a hit sample to all selected s. + /// + /// The name of the hit sample. + public void AddHitSample(string sampleName) + { + EditorBeatmap.PerformOnSelection(h => + { + // Make sure there isn't already an existing sample + if (h.Samples.Any(s => s.Name == sampleName)) + return; + + h.Samples.Add(new HitSampleInfo(sampleName)); + }); + } + + /// + /// Removes a hit sample from all selected s. + /// + /// The name of the hit sample. + public void RemoveHitSample(string sampleName) + { + EditorBeatmap.PerformOnSelection(h => h.SamplesBindable.RemoveAll(s => s.Name == sampleName)); + } + + /// + /// Set the new combo state of all selected s. + /// + /// Whether to set or unset. + /// Throws if any selected object doesn't implement + public void SetNewCombo(bool state) + { + EditorBeatmap.PerformOnSelection(h => + { + var comboInfo = h as IHasComboInformation; + + if (comboInfo == null || comboInfo.NewCombo == state) return; + + comboInfo.NewCombo = state; + EditorBeatmap.Update(h); + }); + } + + #endregion + + #region Context Menu + + /// + /// Provide context menu items relevant to current selection. Calling base is not required. + /// + /// The current selection. + /// The relevant menu items. + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + { + if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) + { + yield return new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; + } + + yield return new OsuMenuItem("Sound") + { + Items = SelectionSampleStates.Select(kvp => + new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + }; + } + + #endregion + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index d612cf3fe0..4078661a26 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -11,17 +11,17 @@ using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// A container for ordered by their start times. + /// A container for ordered by their start times. /// - public sealed class HitObjectOrderedSelectionContainer : Container + public sealed class HitObjectOrderedSelectionContainer : Container> { - public override void Add(SelectionBlueprint drawable) + public override void Add(SelectionBlueprint drawable) { base.Add(drawable); bindStartTime(drawable); } - public override bool Remove(SelectionBlueprint drawable) + public override bool Remove(SelectionBlueprint drawable) { if (!base.Remove(drawable)) return false; @@ -36,11 +36,11 @@ namespace osu.Game.Screens.Edit.Compose.Components unbindAllStartTimes(); } - private readonly Dictionary startTimeMap = new Dictionary(); + private readonly Dictionary, IBindable> startTimeMap = new Dictionary, IBindable>(); - private void bindStartTime(SelectionBlueprint blueprint) + private void bindStartTime(SelectionBlueprint blueprint) { - var bindable = blueprint.HitObject.StartTimeBindable.GetBoundCopy(); + var bindable = blueprint.Item.StartTimeBindable.GetBoundCopy(); bindable.BindValueChanged(_ => { @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components startTimeMap[blueprint] = bindable; } - private void unbindStartTime(SelectionBlueprint blueprint) + private void unbindStartTime(SelectionBlueprint blueprint) { startTimeMap[blueprint].UnbindAll(); startTimeMap.Remove(blueprint); @@ -66,16 +66,16 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override int Compare(Drawable x, Drawable y) { - var xObj = (SelectionBlueprint)x; - var yObj = (SelectionBlueprint)y; + var xObj = (SelectionBlueprint)x; + var yObj = (SelectionBlueprint)y; // Put earlier blueprints towards the end of the list, so they handle input first - int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime); + int i = yObj.Item.StartTime.CompareTo(xObj.Item.StartTime); if (i != 0) return i; // Fall back to end time if the start time is equal. - i = yObj.HitObject.GetEndTime().CompareTo(xObj.HitObject.GetEndTime()); + i = yObj.Item.GetEndTime().CompareTo(xObj.Item.GetEndTime()); return i == 0 ? CompareReverseChildID(y, x) : i; } diff --git a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs index 0792d0f80e..4d4f4b76c6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs +++ b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs @@ -9,29 +9,22 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// An event which occurs when a is moved. /// - public class MoveSelectionEvent + public class MoveSelectionEvent { /// - /// The that triggered this . + /// The that triggered this . /// - public readonly SelectionBlueprint Blueprint; + public readonly SelectionBlueprint Blueprint; /// - /// The expected screen-space position of the hitobject at the current cursor position. + /// The screen-space delta of this move event. /// - public readonly Vector2 ScreenSpacePosition; + public readonly Vector2 ScreenSpaceDelta; - /// - /// The distance between and the hitobject's current position, in the coordinate-space of the hitobject's parent. - /// - public readonly Vector2 InstantDelta; - - public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpacePosition) + public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpaceDelta) { Blueprint = blueprint; - ScreenSpacePosition = screenSpacePosition; - - InstantDelta = Blueprint.GetInstantDelta(ScreenSpacePosition); + ScreenSpaceDelta = screenSpaceDelta; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 9d6b44e207..0d6cfc0689 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Input; @@ -16,6 +17,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { public class SelectionBox : CompositeDrawable { + public const float BORDER_RADIUS = 3; + public Func OnRotation; public Func OnScale; public Func OnFlip; @@ -92,21 +95,32 @@ namespace osu.Game.Screens.Edit.Compose.Components } } + private string text; + + public string Text + { + get => text; + set + { + if (value == text) + return; + + text = value; + if (selectionDetailsText != null) + selectionDetailsText.Text = value; + } + } + private Container dragHandles; private FillFlowContainer buttons; - public const float BORDER_RADIUS = 3; + private OsuSpriteText selectionDetailsText; [Resolved] private OsuColour colours { get; set; } [BackgroundDependencyLoader] - private void load() - { - RelativeSizeAxes = Axes.Both; - - recreate(); - } + private void load() => recreate(); protected override bool OnKeyDown(KeyDownEvent e) { @@ -144,6 +158,26 @@ namespace osu.Game.Screens.Edit.Compose.Components InternalChildren = new Drawable[] { + 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), + Text = text, + } + } + }, new Container { Masking = true, diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index b06e982859..6d8ae69812 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -4,96 +4,64 @@ using System; using System.Collections.Generic; using System.Linq; -using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -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; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Types; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// A component which outlines s and handles movement of selections. + /// A component which outlines items and handles movement of selections. /// - public class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IHasContextMenu + public abstract class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { /// /// The currently selected blueprints. /// Should be used when operations are dealing directly with the visible blueprints. - /// For more general selection operations, use instead. + /// For more general selection operations, use instead. /// - public IEnumerable SelectedBlueprints => selectedBlueprints; + public IReadOnlyList> SelectedBlueprints => selectedBlueprints; - private readonly List selectedBlueprints; + /// + /// The currently selected items. + /// + public readonly BindableList SelectedItems = new BindableList(); - private Drawable content; - - private OsuSpriteText selectionDetailsText; + private readonly List> selectedBlueprints; protected SelectionBox SelectionBox { get; private set; } - [Resolved] - protected EditorBeatmap EditorBeatmap { get; private set; } - [Resolved(CanBeNull = true)] protected IEditorChangeHandler ChangeHandler { get; private set; } - public SelectionHandler() + protected SelectionHandler() { - selectedBlueprints = new List(); + selectedBlueprints = new List>(); RelativeSizeAxes = Axes.Both; AlwaysPresent = true; - Alpha = 0; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - createStateBindables(); + InternalChild = SelectionBox = CreateSelectionBox(); - InternalChild = content = new Container + SelectedItems.CollectionChanged += (sender, args) => { - Children = new Drawable[] - { - // todo: should maybe be inside the SelectionBox? - 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) - } - } - }, - SelectionBox = CreateSelectionBox(), - } + Scheduler.AddOnce(updateVisibility); }; } @@ -128,45 +96,44 @@ namespace osu.Game.Screens.Edit.Compose.Components #region User Input Handling /// - /// Handles the selected s being moved. + /// Handles the selected items being moved. /// /// - /// Just returning true is enough to allow updates to take place. + /// Just returning true is enough to allow default movement to take place. /// Custom implementation is only required if other attributes are to be considered, like changing columns. /// /// The move event. /// - /// Whether any s could be moved. - /// Returning true will also propagate StartTime changes provided by the closest . + /// Whether any items could be moved. /// - public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; + public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; /// - /// Handles the selected s being rotated. + /// Handles the selected items being rotated. /// /// The delta angle to apply to the selection. - /// Whether any s could be rotated. + /// Whether any items could be rotated. public virtual bool HandleRotation(float angle) => false; /// - /// Handles the selected s being scaled. + /// Handles the selected items being scaled. /// - /// The delta scale to apply, in playfield local coordinates. + /// The delta scale to apply, in local coordinates. /// The point of reference where the scale is originating from. - /// Whether any s could be scaled. + /// Whether any items could be scaled. public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; /// - /// Handles the selected s being flipped. + /// Handles the selected items being flipped. /// /// The direction to flip - /// Whether any s could be flipped. + /// Whether any items could be flipped. public virtual bool HandleFlip(Direction direction) => false; /// - /// Handles the selected s being reversed pattern-wise. + /// Handles the selected items being reversed pattern-wise. /// - /// Whether any s could be reversed. + /// Whether any items could be reversed. public virtual bool HandleReverse() => false; public bool OnPressed(PlatformAction action) @@ -174,7 +141,7 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (action.ActionMethod) { case PlatformActionMethod.Delete: - deleteSelected(); + DeleteSelected(); return true; } @@ -198,24 +165,23 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Handle a blueprint becoming selected. /// /// The blueprint. - internal void HandleSelected(SelectionBlueprint blueprint) + internal virtual void HandleSelected(SelectionBlueprint blueprint) { - selectedBlueprints.Add(blueprint); + // there are potentially multiple SelectionHandlers active, but we only want to add items to the selected list once. + if (!SelectedItems.Contains(blueprint.Item)) + SelectedItems.Add(blueprint.Item); - // there are potentially multiple SelectionHandlers active, but we only want to add hitobjects to the selected list once. - if (!EditorBeatmap.SelectedHitObjects.Contains(blueprint.HitObject)) - EditorBeatmap.SelectedHitObjects.Add(blueprint.HitObject); + selectedBlueprints.Add(blueprint); } /// /// Handle a blueprint becoming deselected. /// /// The blueprint. - internal void HandleDeselected(SelectionBlueprint blueprint) + internal virtual void HandleDeselected(SelectionBlueprint blueprint) { + SelectedItems.Remove(blueprint.Item); selectedBlueprints.Remove(blueprint); - - EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject); } /// @@ -224,7 +190,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The blueprint. /// The mouse event responsible for selection. /// Whether a selection was performed. - internal bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + internal bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (e.ShiftPressed && e.Button == MouseButton.Right) { @@ -248,7 +214,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The blueprint. /// The mouse event responsible for deselection. /// Whether a deselection was performed. - internal bool MouseUpSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + internal bool MouseUpSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (blueprint.IsSelected) { @@ -259,23 +225,40 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - private void handleQuickDeletion(SelectionBlueprint blueprint) + private void handleQuickDeletion(SelectionBlueprint blueprint) { if (blueprint.HandleQuickDeletion()) return; if (!blueprint.IsSelected) - EditorBeatmap.Remove(blueprint.HitObject); + DeleteItems(new[] { blueprint.Item }); else - deleteSelected(); + DeleteSelected(); } + /// + /// Given a selection target and a function of truth, retrieve the correct ternary state for display. + /// + protected static TernaryState GetStateFromSelection(IEnumerable selection, Func func) + { + if (selection.Any(func)) + return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; + + return TernaryState.False; + } + + /// + /// Called whenever the deletion of items has been requested. + /// + /// The items to be deleted. + protected abstract void DeleteItems(IEnumerable items); + /// /// Ensure the blueprint is in a selected state. /// /// The blueprint to select. /// Whether selection state was changed. - private bool ensureSelected(SelectionBlueprint blueprint) + private bool ensureSelected(SelectionBlueprint blueprint) { if (blueprint.IsSelected) return false; @@ -285,9 +268,9 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - private void deleteSelected() + protected void DeleteSelected() { - EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject)); + DeleteItems(selectedBlueprints.Select(b => b.Item)); } #endregion @@ -295,20 +278,20 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Outline Display /// - /// Updates whether this is visible. + /// Updates whether this is visible. /// private void updateVisibility() { - int count = EditorBeatmap.SelectedHitObjects.Count; + int count = SelectedItems.Count; - selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty; + SelectionBox.Text = count > 0 ? count.ToString() : string.Empty; + SelectionBox.FadeTo(count > 0 ? 1 : 0); - this.FadeTo(count > 0 ? 1 : 0); OnSelectionChanged(); } /// - /// Triggered whenever the set of selected objects changes. + /// Triggered whenever the set of selected items changes. /// Should update the selection box's state to match supported operations. /// protected virtual void OnSelectionChanged() @@ -322,159 +305,16 @@ namespace osu.Game.Screens.Edit.Compose.Components if (selectedBlueprints.Count == 0) return; - // Move the rectangle to cover the hitobjects - var topLeft = new Vector2(float.MaxValue, float.MaxValue); - var bottomRight = new Vector2(float.MinValue, float.MinValue); + // Move the rectangle to cover the items + RectangleF selectionRect = ToLocalSpace(selectedBlueprints[0].SelectionQuad).AABBFloat; - foreach (var blueprint in selectedBlueprints) - { - topLeft = Vector2.ComponentMin(topLeft, ToLocalSpace(blueprint.SelectionQuad.TopLeft)); - bottomRight = Vector2.ComponentMax(bottomRight, ToLocalSpace(blueprint.SelectionQuad.BottomRight)); - } + for (int i = 1; i < selectedBlueprints.Count; i++) + selectionRect = RectangleF.Union(selectionRect, ToLocalSpace(selectedBlueprints[i].SelectionQuad).AABBFloat); - topLeft -= new Vector2(5); - bottomRight += new Vector2(5); + selectionRect = selectionRect.Inflate(5f); - content.Size = bottomRight - topLeft; - content.Position = topLeft; - } - - #endregion - - #region Sample Changes - - /// - /// Adds a hit sample to all selected s. - /// - /// The name of the hit sample. - public void AddHitSample(string sampleName) - { - EditorBeatmap.PerformOnSelection(h => - { - // Make sure there isn't already an existing sample - if (h.Samples.Any(s => s.Name == sampleName)) - return; - - h.Samples.Add(new HitSampleInfo(sampleName)); - }); - } - - /// - /// Set the new combo state of all selected s. - /// - /// Whether to set or unset. - /// Throws if any selected object doesn't implement - public void SetNewCombo(bool state) - { - EditorBeatmap.PerformOnSelection(h => - { - var comboInfo = h as IHasComboInformation; - - if (comboInfo == null || comboInfo.NewCombo == state) return; - - comboInfo.NewCombo = state; - EditorBeatmap.Update(h); - }); - } - - /// - /// Removes a hit sample from all selected s. - /// - /// The name of the hit sample. - public void RemoveHitSample(string sampleName) - { - EditorBeatmap.PerformOnSelection(h => h.SamplesBindable.RemoveAll(s => s.Name == sampleName)); - } - - #endregion - - #region Selection State - - /// - /// The state of "new combo" for all selected hitobjects. - /// - public readonly Bindable SelectionNewComboState = new Bindable(); - - /// - /// The state of each sample type for all selected hitobjects. Keys match with constant specifications. - /// - public readonly Dictionary> SelectionSampleStates = new Dictionary>(); - - /// - /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) - /// - private void createStateBindables() - { - foreach (var sampleName in HitSampleInfo.AllAdditions) - { - var bindable = new Bindable - { - Description = sampleName.Replace("hit", string.Empty).Titleize() - }; - - bindable.ValueChanged += state => - { - switch (state.NewValue) - { - case TernaryState.False: - RemoveHitSample(sampleName); - break; - - case TernaryState.True: - AddHitSample(sampleName); - break; - } - }; - - SelectionSampleStates[sampleName] = bindable; - } - - // new combo - SelectionNewComboState.ValueChanged += state => - { - switch (state.NewValue) - { - case TernaryState.False: - SetNewCombo(false); - break; - - case TernaryState.True: - SetNewCombo(true); - break; - } - }; - - // bring in updates from selection changes - EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); - EditorBeatmap.SelectedHitObjects.CollectionChanged += (sender, args) => - { - Scheduler.AddOnce(updateVisibility); - Scheduler.AddOnce(UpdateTernaryStates); - }; - } - - /// - /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). - /// - protected virtual void UpdateTernaryStates() - { - SelectionNewComboState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.NewCombo); - - foreach (var (sampleName, bindable) in SelectionSampleStates) - { - bindable.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName)); - } - } - - /// - /// Given a selection target and a function of truth, retrieve the correct ternary state for display. - /// - protected TernaryState GetStateFromSelection(IEnumerable selection, Func func) - { - if (selection.Any(func)) - return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; - - return TernaryState.False; + SelectionBox.Position = selectionRect.Location; + SelectionBox.Size = selectionRect.Size; } #endregion @@ -485,30 +325,17 @@ namespace osu.Game.Screens.Edit.Compose.Components { get { - if (!selectedBlueprints.Any(b => b.IsHovered)) + if (!SelectedBlueprints.Any(b => b.IsHovered)) return Array.Empty(); var items = new List(); - items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints)); + items.AddRange(GetContextMenuItemsForSelection(SelectedBlueprints)); - if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation)) - { - items.Add(new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }); - } + if (SelectedBlueprints.Count == 1) + items.AddRange(SelectedBlueprints[0].ContextMenuItems); - if (selectedBlueprints.Count == 1) - items.AddRange(selectedBlueprints[0].ContextMenuItems); - - items.AddRange(new[] - { - new OsuMenuItem("Sound") - { - Items = SelectionSampleStates.Select(kvp => - new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() - }, - new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected), - }); + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, DeleteSelected)); return items.ToArray(); } @@ -519,7 +346,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// The current selection. /// The relevant menu items. - protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) + protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) => Enumerable.Empty(); #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 7a3781a981..7c1bbd65f9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -12,9 +12,11 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -23,20 +25,17 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - internal class TimelineBlueprintContainer : BlueprintContainer + internal class TimelineBlueprintContainer : EditorBlueprintContainer { [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - [Resolved] - private EditorBeatmap beatmap { get; set; } - [Resolved] private OsuColour colours { get; set; } private DragEvent lastDragEvent; private Bindable placement; - private SelectionBlueprint placementBlueprint; + private SelectionBlueprint placementBlueprint; private SelectableAreaBackground backgroundBox; @@ -74,7 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.LoadComplete(); DragBox.Alpha = 0; - placement = beatmap.PlacementObject.GetBoundCopy(); + placement = Beatmap.PlacementObject.GetBoundCopy(); placement.ValueChanged += placementChanged; } @@ -98,7 +97,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected override Container CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; protected override bool OnHover(HoverEvent e) { @@ -158,7 +157,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // remove objects from the stack as long as their end time is in the past. while (currentConcurrentObjects.TryPeek(out HitObject hitObject)) { - if (Precision.AlmostBigger(hitObject.GetEndTime(), b.HitObject.StartTime, 1)) + if (Precision.AlmostBigger(hitObject.GetEndTime(), b.Item.StartTime, 1)) break; currentConcurrentObjects.Pop(); @@ -166,7 +165,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // if the stack gets too high, we should have space below it to display the next batch of objects. // importantly, we only do this if time has incremented, else a stack of hitobjects all at the same time value would start to overlap themselves. - if (currentConcurrentObjects.TryPeek(out HitObject h) && !Precision.AlmostEquals(h.StartTime, b.HitObject.StartTime, 1)) + if (currentConcurrentObjects.TryPeek(out HitObject h) && !Precision.AlmostEquals(h.StartTime, b.Item.StartTime, 1)) { if (currentConcurrentObjects.Count >= stack_reset_count) currentConcurrentObjects.Clear(); @@ -174,15 +173,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline b.Y = -(stack_offset * currentConcurrentObjects.Count); - currentConcurrentObjects.Push(b.HitObject); + currentConcurrentObjects.Push(b.Item); } } - protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler(); - protected override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) + protected override SelectionBlueprint CreateBlueprintFor(HitObject item) { - return new TimelineHitObjectBlueprint(hitObject) + return new TimelineHitObjectBlueprint(item) { OnDragHandled = handleScrollViaDrag }; @@ -237,10 +236,48 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - internal class TimelineSelectionHandler : SelectionHandler + internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler { // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation - public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.EditorNudgeLeft: + nudgeSelection(-1); + return true; + + case GlobalAction.EditorNudgeRight: + nudgeSelection(1); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + + /// + /// Nudge the current selection by the specified multiple of beat divisor lengths, + /// based on the timing at the first object in the selection. + /// + /// The direction and count of beat divisor lengths to adjust. + private void nudgeSelection(int amount) + { + var selected = EditorBeatmap.SelectedHitObjects; + + if (selected.Count == 0) + return; + + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(selected.First().StartTime); + double adjustment = timingPoint.BeatLength / EditorBeatmap.BeatDivisor * amount; + + EditorBeatmap.PerformOnSelection(h => h.StartTime += adjustment); + } } private class TimelineDragBox : DragBox @@ -304,13 +341,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected class TimelineSelectionBlueprintContainer : Container + protected class TimelineSelectionBlueprintContainer : Container> { - protected override Container Content { get; } + protected override Container> Content { get; } public TimelineSelectionBlueprintContainer() { - AddInternal(new TimelinePart(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); + AddInternal(new TimelinePart>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 0425370ae5..dbe689be2f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -26,7 +26,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineHitObjectBlueprint : SelectionBlueprint + public class TimelineHitObjectBlueprint : SelectionBlueprint { private const float circle_size = 38; @@ -49,13 +49,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private ISkinSource skin { get; set; } - public TimelineHitObjectBlueprint(HitObject hitObject) - : base(hitObject) + public TimelineHitObjectBlueprint(HitObject item) + : base(item) { Anchor = Anchor.CentreLeft; Origin = Anchor.CentreLeft; - startTime = hitObject.StartTimeBindable.GetBoundCopy(); + startTime = item.StartTimeBindable.GetBoundCopy(); startTime.BindValueChanged(time => X = (float)time.NewValue, true); RelativePositionAxes = Axes.X; @@ -95,9 +95,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, }); - if (hitObject is IHasDuration) + if (item is IHasDuration) { - colouredComponents.Add(new DragArea(hitObject) + colouredComponents.Add(new DragArea(item) { OnDragHandled = e => OnDragHandled?.Invoke(e) }); @@ -108,7 +108,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.LoadComplete(); - if (HitObject is IHasComboInformation comboInfo) + if (Item is IHasComboInformation comboInfo) { indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); @@ -136,7 +136,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateComboColour() { - if (!(HitObject is IHasComboInformation combo)) + if (!(Item is IHasComboInformation combo)) return; var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); @@ -152,7 +152,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline border.Hide(); } - if (HitObject is IHasDuration duration && duration.Duration > 0) + if (Item is IHasDuration duration && duration.Duration > 0) circle.Colour = ColourInfo.GradientHorizontal(comboColour, comboColour.Lighten(0.4f)); else circle.Colour = comboColour; @@ -166,14 +166,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.Update(); // no bindable so we perform this every update - float duration = (float)(HitObject.GetEndTime() - HitObject.StartTime); + float duration = (float)(Item.GetEndTime() - Item.StartTime); if (Width != duration) { Width = duration; // kind of haphazard but yeah, no bindables. - if (HitObject is IHasRepeats repeats) + if (Item is IHasRepeats repeats) updateRepeats(repeats); } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index fffea65456..434683a016 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -17,7 +17,6 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -144,8 +143,7 @@ namespace osu.Game.Screens.Edit // Todo: should probably be done at a DrawableRuleset level to share logic with Player. clock = new EditorClock(playableBeatmap, beatDivisor) { IsCoupled = false }; - - UpdateClockSource(); + clock.ChangeSource(loadableBeatmap.Track); dependencies.CacheAs(clock); AddInternal(clock); @@ -224,9 +222,10 @@ namespace osu.Game.Screens.Edit }, new MenuItem("View") { - Items = new[] + Items = new MenuItem[] { - new WaveformOpacityMenu(config) + new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), + new HitAnimationsMenuItem(config.GetBindable(OsuSetting.EditorHitAnimations)) } } } @@ -307,11 +306,7 @@ namespace osu.Game.Screens.Edit /// /// If the beatmap's track has changed, this method must be called to keep the editor in a valid state. /// - public void UpdateClockSource() - { - var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock(); - clock.ChangeSource(sourceClock); - } + public void UpdateClockSource() => clock.ChangeSource(Beatmap.Value.Track); protected void Save() { @@ -582,7 +577,7 @@ namespace osu.Game.Screens.Edit private void resetTrack(bool seekToStart = false) { - Beatmap.Value.Track?.Stop(); + Beatmap.Value.Track.Stop(); if (seekToStart) { diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 4bf4a3b8f3..be53abbd55 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -301,13 +301,7 @@ namespace osu.Game.Screens.Edit return list.Count - 1; } - public double SnapTime(double time, double? referenceTime) - { - var timingPoint = ControlPointInfo.TimingPointAt(referenceTime ?? time); - var beatLength = timingPoint.BeatLength / BeatDivisor; - - return timingPoint.Time + (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero) * beatLength; - } + public double SnapTime(double time, double? referenceTime) => ControlPointInfo.GetClosestSnappedTime(time, BeatDivisor, referenceTime); public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; diff --git a/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs b/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs new file mode 100644 index 0000000000..fb7ab39f7a --- /dev/null +++ b/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Edit +{ + internal class HitAnimationsMenuItem : ToggleMenuItem + { + [UsedImplicitly] + private readonly Bindable hitAnimations; + + public HitAnimationsMenuItem(Bindable hitAnimations) + : base("Hit animations") + { + State.BindTo(this.hitAnimations = hitAnimations); + } + } +} diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index f2e0320ce3..66784fda54 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -116,6 +116,8 @@ namespace osu.Game.Screens.Edit protected override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); } } } diff --git a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs index a2ee153951..9de1f04271 100644 --- a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs +++ b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; @@ -60,7 +61,10 @@ namespace osu.Game.Screens.Edit.Verify private EditorClock clock { get; set; } [Resolved] - protected EditorBeatmap Beatmap { get; private set; } + private IBindable workingBeatmap { get; set; } + + [Resolved] + private EditorBeatmap beatmap { get; set; } [Resolved] private Bindable selectedIssue { get; set; } @@ -72,7 +76,7 @@ namespace osu.Game.Screens.Edit.Verify private void load(OverlayColourProvider colours) { generalVerifier = new BeatmapVerifier(); - rulesetVerifier = Beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateBeatmapVerifier(); + rulesetVerifier = beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateBeatmapVerifier(); RelativeSizeAxes = Axes.Both; @@ -118,10 +122,10 @@ namespace osu.Game.Screens.Edit.Verify private void refresh() { - var issues = generalVerifier.Run(Beatmap); + var issues = generalVerifier.Run(beatmap, workingBeatmap.Value); if (rulesetVerifier != null) - issues = issues.Concat(rulesetVerifier.Run(Beatmap)); + issues = issues.Concat(rulesetVerifier.Run(beatmap, workingBeatmap.Value)); table.Issues = issues .OrderBy(issue => issue.Template.Type) diff --git a/osu.Game/Screens/Edit/WaveformOpacityMenu.cs b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs similarity index 85% rename from osu.Game/Screens/Edit/WaveformOpacityMenu.cs rename to osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs index 5d209ae141..053c2fa4b0 100644 --- a/osu.Game/Screens/Edit/WaveformOpacityMenu.cs +++ b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs @@ -4,18 +4,17 @@ using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; -using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Edit { - internal class WaveformOpacityMenu : MenuItem + internal class WaveformOpacityMenuItem : MenuItem { private readonly Bindable waveformOpacity; private readonly Dictionary menuItemLookup = new Dictionary(); - public WaveformOpacityMenu(OsuConfigManager config) + public WaveformOpacityMenuItem(Bindable waveformOpacity) : base("Waveform opacity") { Items = new[] @@ -26,7 +25,7 @@ namespace osu.Game.Screens.Edit createMenuItem(1f), }; - waveformOpacity = config.GetBindable(OsuSetting.EditorWaveformOpacity); + this.waveformOpacity = waveformOpacity; waveformOpacity.BindValueChanged(opacity => { foreach (var kvp in menuItemLookup) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index aaacf891bb..ae2042fbe8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowSkippingIntro = false, + AllowSkipping = false, }) { this.userIds = userIds; diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 58eb95b7c6..f8cedddfbe 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Storyboards; @@ -19,6 +20,14 @@ namespace osu.Game.Screens.Play private readonly Storyboard storyboard; private DrawableStoryboard drawableStoryboard; + /// + /// Whether the storyboard is considered finished. + /// + /// + /// This is true by default in here, until an actual drawable storyboard is loaded, in which case it'll bind to it. + /// + public IBindable HasStoryboardEnded = new BindableBool(true); + public DimmableStoryboard(Storyboard storyboard) { this.storyboard = storyboard; @@ -49,6 +58,7 @@ namespace osu.Game.Screens.Play return; drawableStoryboard = storyboard.CreateDrawable(); + HasStoryboardEnded.BindTo(drawableStoryboard.HasStoryboardEnded); if (async) LoadComponentAsync(drawableStoryboard, onStoryboardCreated); diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs index db4b5d300b..54aa395f5f 100644 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ b/osu.Game/Screens/Play/GameplayClock.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Play /// public class GameplayClock : IFrameBasedClock { - private readonly IFrameBasedClock underlyingClock; + internal readonly IFrameBasedClock UnderlyingClock; public readonly BindableBool IsPaused = new BindableBool(); @@ -30,12 +30,12 @@ namespace osu.Game.Screens.Play public GameplayClock(IFrameBasedClock underlyingClock) { - this.underlyingClock = underlyingClock; + UnderlyingClock = underlyingClock; } - public double CurrentTime => underlyingClock.CurrentTime; + public double CurrentTime => UnderlyingClock.CurrentTime; - public double Rate => underlyingClock.Rate; + public double Rate => UnderlyingClock.Rate; /// /// The rate of gameplay when playback is at 100%. @@ -59,19 +59,19 @@ namespace osu.Game.Screens.Play } } - public bool IsRunning => underlyingClock.IsRunning; + public bool IsRunning => UnderlyingClock.IsRunning; public void ProcessFrame() { // intentionally not updating the underlying clock (handled externally). } - public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; + public double ElapsedFrameTime => UnderlyingClock.ElapsedFrameTime; - public double FramesPerSecond => underlyingClock.FramesPerSecond; + public double FramesPerSecond => UnderlyingClock.FramesPerSecond; - public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo; + public FrameTimeInfo TimeInfo => UnderlyingClock.TimeInfo; - public IClock Source => underlyingClock; + public IClock Source => UnderlyingClock; } } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index ddbb087962..1c8a3e51ac 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -1,300 +1,161 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; -using osu.Game.Beatmaps; -using osu.Game.Configuration; namespace osu.Game.Screens.Play { /// - /// Encapsulates gameplay timing logic and provides a for children. + /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// - public class GameplayClockContainer : Container + public abstract class GameplayClockContainer : Container { - private readonly WorkingBeatmap beatmap; - - [NotNull] - private ITrack track; + /// + /// The final clock which is exposed to gameplay components. + /// + public GameplayClock GameplayClock { get; private set; } + /// + /// Whether gameplay is paused. + /// public readonly BindableBool IsPaused = new BindableBool(); /// - /// The decoupled clock used for gameplay. Should be used for seeks and clock control. + /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. /// - private readonly DecoupleableInterpolatingFramedClock adjustableClock; - - private readonly double gameplayStartTime; - private readonly bool startAtGameplayStart; - - private readonly double firstHitObjectTime; - - public readonly BindableNumber UserPlaybackRate = new BindableDouble(1) - { - Default = 1, - MinValue = 0.5, - MaxValue = 2, - Precision = 0.1, - }; + protected readonly DecoupleableInterpolatingFramedClock AdjustableSource; /// - /// The final clock which is exposed to underlying components. + /// The source clock. /// - public GameplayClock GameplayClock => localGameplayClock; - - [Cached(typeof(GameplayClock))] - private readonly LocalGameplayClock localGameplayClock; - - private Bindable userAudioOffset; - - private readonly FramedOffsetClock userOffsetClock; - - private readonly FramedOffsetClock platformOffsetClock; + protected IClock SourceClock { get; private set; } /// /// Creates a new . /// - /// The beatmap being played. - /// The suggested time to start gameplay at. - /// - /// Whether should be used regardless of when storyboard events and hitobjects are supposed to start. - /// - public GameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) + /// The source used for timing. + protected GameplayClockContainer(IClock sourceClock) { - this.beatmap = beatmap; - this.gameplayStartTime = gameplayStartTime; - this.startAtGameplayStart = startAtGameplayStart; - track = beatmap.Track; - - firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; + SourceClock = sourceClock; RelativeSizeAxes = Axes.Both; - adjustableClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; + AdjustableSource = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; + IsPaused.BindValueChanged(OnIsPausedChanged); + } - // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. - // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new HardwareCorrectionOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; - - // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); - - // the clock to be exposed via DI to children. - localGameplayClock = new LocalGameplayClock(userOffsetClock); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(GameplayClock = CreateGameplayClock(AdjustableSource)); GameplayClock.IsPaused.BindTo(IsPaused); - IsPaused.BindValueChanged(onPauseChanged); + return dependencies; } - private void onPauseChanged(ValueChangedEvent isPaused) - { - if (isPaused.NewValue) - this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => adjustableClock.Stop()); - else - this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); - } - - private double totalOffset => userOffsetClock.Offset + platformOffsetClock.Offset; - /// - /// Duration before gameplay start time required before skip button displays. + /// Starts gameplay. /// - public const double MINIMUM_SKIP_TIME = 1000; - - private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + public virtual void Start() { - userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); - userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true); + ensureSourceClockSet(); - // sane default provided by ruleset. - double startTime = gameplayStartTime; - - if (!startAtGameplayStart) - { - startTime = Math.Min(0, startTime); - - // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. - // this is commonly used to display an intro before the audio track start. - double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; - if (firstStoryboardEvent != null) - startTime = Math.Min(startTime, firstStoryboardEvent.Value); - - // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. - // this is not available as an option in the live editor but can still be applied via .osu editing. - if (beatmap.BeatmapInfo.AudioLeadIn > 0) - startTime = Math.Min(startTime, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); - } - - Seek(startTime); - - adjustableClock.ProcessFrame(); - } - - public void Restart() - { - Task.Run(() => - { - track.Seek(0); - track.Stop(); - - Schedule(() => - { - adjustableClock.ChangeSource(track); - updateRate(); - - if (!IsPaused.Value) - Start(); - }); - }); - } - - public void Start() - { - if (!adjustableClock.IsRunning) + if (!AdjustableSource.IsRunning) { // Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time - // This accounts for the audio clock source potentially taking time to enter a completely stopped state + // This accounts for the clock source potentially taking time to enter a completely stopped state Seek(GameplayClock.CurrentTime); - adjustableClock.Start(); + AdjustableSource.Start(); } IsPaused.Value = false; } - /// - /// Skip forward to the next valid skip point. - /// - public void Skip() - { - if (GameplayClock.CurrentTime > gameplayStartTime - MINIMUM_SKIP_TIME) - return; - - double skipTarget = gameplayStartTime - MINIMUM_SKIP_TIME; - - if (GameplayClock.CurrentTime < 0 && skipTarget > 6000) - // double skip exception for storyboards with very long intros - skipTarget = 0; - - Seek(skipTarget); - } - /// /// Seek to a specific time in gameplay. - /// - /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track). - /// /// /// The destination time to seek to. - public void Seek(double time) + public virtual void Seek(double time) { - // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track. - // we may want to consider reversing the application of offsets in the future as it may feel more correct. - adjustableClock.Seek(time - totalOffset); + AdjustableSource.Seek(time); - // manually process frame to ensure GameplayClock is correctly updated after a seek. - userOffsetClock.ProcessFrame(); - } - - public void Stop() - { - IsPaused.Value = true; + // Manually process to make sure the gameplay clock is correctly updated after a seek. + GameplayClock.UnderlyingClock.ProcessFrame(); } /// - /// Changes the backing clock to avoid using the originally provided track. + /// Stops gameplay. /// - public void StopUsingBeatmapClock() - { - removeSourceClockAdjustments(); + public virtual void Stop() => IsPaused.Value = true; - track = new TrackVirtual(track.Length); - adjustableClock.ChangeSource(track); + /// + /// Resets this and the source to an initial state ready for gameplay. + /// + public virtual void Reset() + { + ensureSourceClockSet(); + Seek(0); + + // Manually stop the source in order to not affect the IsPaused state. + AdjustableSource.Stop(); + + if (!IsPaused.Value) + Start(); + } + + /// + /// Changes the source clock. + /// + /// The new source. + protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock); + + /// + /// Ensures that the is set to , if it hasn't been given a source yet. + /// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode, + /// but not the actual source clock. + /// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor, + /// but it is not yet set on the adjustable source there. + /// + private void ensureSourceClockSet() + { + if (AdjustableSource.Source == null) + ChangeSource(SourceClock); } protected override void Update() { if (!IsPaused.Value) - { - userOffsetClock.ProcessFrame(); - } + GameplayClock.UnderlyingClock.ProcessFrame(); base.Update(); } - private bool speedAdjustmentsApplied; - - private void updateRate() + /// + /// Invoked when the value of is changed to start or stop the clock. + /// + /// Whether the clock should now be paused. + protected virtual void OnIsPausedChanged(ValueChangedEvent isPaused) { - if (speedAdjustmentsApplied) - return; - - track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - - localGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); - localGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); - - speedAdjustmentsApplied = true; + if (isPaused.NewValue) + AdjustableSource.Stop(); + else + AdjustableSource.Start(); } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - removeSourceClockAdjustments(); - } - - private void removeSourceClockAdjustments() - { - if (!speedAdjustmentsApplied) return; - - track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - - localGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); - localGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); - - speedAdjustmentsApplied = false; - } - - private class LocalGameplayClock : GameplayClock - { - public readonly List> MutableNonGameplayAdjustments = new List>(); - - public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; - - public LocalGameplayClock(FramedOffsetClock underlyingClock) - : base(underlyingClock) - { - } - } - - private class HardwareCorrectionOffsetClock : FramedOffsetClock - { - // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. - // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. - public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); - - public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) - : base(source, processSource) - { - } - } + /// + /// Creates the final which is exposed via DI to be used by gameplay components. + /// + /// + /// Any intermediate clocks such as platform offsets should be applied here. + /// + /// The providing the source time. + /// The final . + protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source); } } diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index d5d8ec570a..b8a43708b4 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter + public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter, ISkinnableComponent { private readonly Vector2 offset = new Vector2(-20, 5); diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 63e7a88550..959766ecd1 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, IComboCounter + public class DefaultComboCounter : RollingCounter, IComboCounter, ISkinnableComponent { private readonly Vector2 offset = new Vector2(20, 5); diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index b550b469e9..e3cd71691d 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -16,7 +16,7 @@ using osu.Framework.Utils; namespace osu.Game.Screens.Play.HUD { - public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour + public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableComponent { /// /// The base opacity of the glow. diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 1dcfe2e067..dde5c18b38 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -8,7 +8,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Play.HUD { - public class DefaultScoreCounter : ScoreCounter + public class DefaultScoreCounter : ScoreCounter, ISkinnableComponent { public DefaultScoreCounter() : base(6) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 89f135de7f..3b24c8cc9e 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -18,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { - public class BarHitErrorMeter : HitErrorMeter + public class BarHitErrorMeter : HitErrorMeter, ISkinnableComponent { private readonly Anchor alignment; diff --git a/osu.Game/Screens/Play/HUD/ISkinnableComponent.cs b/osu.Game/Screens/Play/HUD/ISkinnableComponent.cs new file mode 100644 index 0000000000..6d4558443f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ISkinnableComponent.cs @@ -0,0 +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 osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications. + /// + public interface ISkinnableComponent : IDrawable + { + } +} diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index b4604c0d01..4ae722e44c 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Play.HUD /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. /// - public class LegacyComboCounter : CompositeDrawable, IComboCounter + public class LegacyComboCounter : CompositeDrawable, IComboCounter, ISkinnableComponent { public Bindable Current { get; } = new BindableInt { MinValue = 0, }; diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs new file mode 100644 index 0000000000..fcbc6fae15 --- /dev/null +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -0,0 +1,233 @@ +// 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; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; + +namespace osu.Game.Screens.Play +{ + /// + /// A which uses a as a source. + /// + /// This is the most complete which takes into account all user and platform offsets, + /// and provides implementations for user actions such as skipping or adjusting playback rates that may occur during gameplay. + /// + /// + /// + /// This is intended to be used as a single controller for gameplay, or as a reference source for other s. + /// + public class MasterGameplayClockContainer : GameplayClockContainer + { + /// + /// Duration before gameplay start time required before skip button displays. + /// + public const double MINIMUM_SKIP_TIME = 1000; + + protected Track Track => (Track)SourceClock; + + public readonly BindableNumber UserPlaybackRate = new BindableDouble(1) + { + Default = 1, + MinValue = 0.5, + MaxValue = 2, + Precision = 0.1, + }; + + private double totalOffset => userOffsetClock.Offset + platformOffsetClock.Offset; + + private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); + + private readonly WorkingBeatmap beatmap; + private readonly double gameplayStartTime; + private readonly bool startAtGameplayStart; + private readonly double firstHitObjectTime; + + private FramedOffsetClock userOffsetClock; + private FramedOffsetClock platformOffsetClock; + private MasterGameplayClock masterGameplayClock; + private Bindable userAudioOffset; + private double startOffset; + + public MasterGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) + : base(beatmap.Track) + { + this.beatmap = beatmap; + this.gameplayStartTime = gameplayStartTime; + this.startAtGameplayStart = startAtGameplayStart; + + firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); + userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true); + + // sane default provided by ruleset. + startOffset = gameplayStartTime; + + if (!startAtGameplayStart) + { + startOffset = Math.Min(0, startOffset); + + // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. + // this is commonly used to display an intro before the audio track start. + double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; + if (firstStoryboardEvent != null) + startOffset = Math.Min(startOffset, firstStoryboardEvent.Value); + + // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. + // this is not available as an option in the live editor but can still be applied via .osu editing. + if (beatmap.BeatmapInfo.AudioLeadIn > 0) + startOffset = Math.Min(startOffset, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); + } + + Seek(startOffset); + } + + protected override void OnIsPausedChanged(ValueChangedEvent isPaused) + { + // The source is stopped by a frequency fade first. + if (isPaused.NewValue) + this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => AdjustableSource.Stop()); + else + this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); + } + + public override void Start() + { + addSourceClockAdjustments(); + base.Start(); + } + + /// + /// Seek to a specific time in gameplay. + /// + /// + /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track). + /// + /// The destination time to seek to. + public override void Seek(double time) + { + // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track. + // we may want to consider reversing the application of offsets in the future as it may feel more correct. + base.Seek(time - totalOffset); + } + + /// + /// Skip forward to the next valid skip point. + /// + public void Skip() + { + if (GameplayClock.CurrentTime > gameplayStartTime - MINIMUM_SKIP_TIME) + return; + + double skipTarget = gameplayStartTime - MINIMUM_SKIP_TIME; + + if (GameplayClock.CurrentTime < 0 && skipTarget > 6000) + // double skip exception for storyboards with very long intros + skipTarget = 0; + + Seek(skipTarget); + } + + public override void Reset() + { + base.Reset(); + Seek(startOffset); + } + + protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) + { + // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. + // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. + platformOffsetClock = new HardwareCorrectionOffsetClock(source) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + + // the final usable gameplay clock with user-set offsets applied. + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); + + return masterGameplayClock = new MasterGameplayClock(userOffsetClock); + } + + /// + /// Changes the backing clock to avoid using the originally provided track. + /// + public void StopUsingBeatmapClock() + { + removeSourceClockAdjustments(); + ChangeSource(new TrackVirtual(beatmap.Track.Length)); + addSourceClockAdjustments(); + } + + private bool speedAdjustmentsApplied; + + private void addSourceClockAdjustments() + { + if (speedAdjustmentsApplied) + return; + + Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + + masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); + masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); + + speedAdjustmentsApplied = true; + } + + private void removeSourceClockAdjustments() + { + if (!speedAdjustmentsApplied) + return; + + Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + + masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); + masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); + + speedAdjustmentsApplied = false; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + removeSourceClockAdjustments(); + } + + private class HardwareCorrectionOffsetClock : FramedOffsetClock + { + // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. + // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. + public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); + + public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) + : base(source, processSource) + { + } + } + + private class MasterGameplayClock : GameplayClock + { + public readonly List> MutableNonGameplayAdjustments = new List>(); + + public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; + + public MasterGameplayClock(FramedOffsetClock underlyingClock) + : base(underlyingClock) + { + } + } + } +} diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index dd3f58439b..88e617245b 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -104,7 +104,8 @@ namespace osu.Game.Screens.Play private BreakTracker breakTracker; - private SkipOverlay skipOverlay; + private SkipOverlay skipIntroOverlay; + private SkipOverlay skipOutroOverlay; protected ScoreProcessor ScoreProcessor { get; private set; } @@ -244,7 +245,6 @@ namespace osu.Game.Screens.Play HUDOverlay.ShowHud.Value = false; HUDOverlay.ShowHud.Disabled = true; BreakOverlay.Hide(); - skipOverlay.Hide(); } DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting => @@ -281,8 +281,14 @@ namespace osu.Game.Screens.Play ScoreProcessor.RevertResult(r); }; + DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded => + { + if (storyboardEnded.NewValue && completionProgressDelegate == null) + updateCompletionState(); + }; + // Bind the judgement processors to ourselves - ScoreProcessor.HasCompleted.ValueChanged += updateCompletionState; + ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState()); HealthProcessor.Failed += onFail; foreach (var mod in Mods.Value.OfType()) @@ -295,7 +301,7 @@ namespace osu.Game.Screens.Play IsBreakTime.BindValueChanged(onBreakTimeChanged, true); } - protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new GameplayClockContainer(beatmap, gameplayStart); + protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); private Drawable createUnderlayComponents() => DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both }; @@ -342,7 +348,6 @@ namespace osu.Game.Screens.Play Action = () => PerformExit(true), IsPaused = { BindTarget = GameplayClockContainer.IsPaused } }, - PlayerSettingsOverlay = { PlaybackSettings = { UserPlaybackRate = { BindTarget = GameplayClockContainer.UserPlaybackRate } } }, KeyCounter = { AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, @@ -356,10 +361,15 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre }, - skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) + skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { RequestSkip = performUserRequestedSkip }, + skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0) + { + RequestSkip = () => updateCompletionState(true), + Alpha = 0 + }, FailOverlay = new FailOverlay { OnRetry = Restart, @@ -386,8 +396,14 @@ namespace osu.Game.Screens.Play } }; - if (!Configuration.AllowSkippingIntro) - skipOverlay.Expire(); + if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) + { + skipIntroOverlay.Expire(); + skipOutroOverlay.Expire(); + } + + if (GameplayClockContainer is MasterGameplayClockContainer master) + HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate; if (Configuration.AllowRestart) { @@ -523,6 +539,10 @@ namespace osu.Game.Screens.Play Pause(); return; } + + // if the score is ready for display but results screen has not been pushed yet (e.g. storyboard is still playing beyond gameplay), then transition to results screen instead of exiting. + if (prepareScoreForDisplayTask != null) + updateCompletionState(true); } this.Exit(); @@ -533,7 +553,8 @@ namespace osu.Game.Screens.Play // user requested skip // disable sample playback to stop currently playing samples and perform skip samplePlaybackDisabled.Value = true; - GameplayClockContainer.Skip(); + + (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(); // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state updateSampleDisabledState(); @@ -561,17 +582,23 @@ namespace osu.Game.Screens.Play private ScheduledDelegate completionProgressDelegate; private Task prepareScoreForDisplayTask; - private void updateCompletionState(ValueChangedEvent completionState) + /// + /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime. + /// + /// If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it. + /// Thrown if this method is called more than once without changing state. + private void updateCompletionState(bool skipStoryboardOutro = false) { // screen may be in the exiting transition phase. if (!this.IsCurrentScreen()) return; - if (!completionState.NewValue) + if (!ScoreProcessor.HasCompleted.Value) { completionProgressDelegate?.Cancel(); completionProgressDelegate = null; ValidForResume = true; + skipOutroOverlay.Hide(); return; } @@ -611,6 +638,20 @@ namespace osu.Game.Screens.Play return score.ScoreInfo; }); + if (skipStoryboardOutro) + { + scheduleCompletion(); + return; + } + + bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; + + if (storyboardHasOutro) + { + skipOutroOverlay.Show(); + return; + } + using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) scheduleCompletion(); } @@ -808,7 +849,7 @@ namespace osu.Game.Screens.Play if (GameplayClockContainer.GameplayClock.IsRunning) throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"); - GameplayClockContainer.Restart(); + GameplayClockContainer.Reset(); } public override void OnSuspending(IScreen next) @@ -832,7 +873,7 @@ namespace osu.Game.Screens.Play // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. - GameplayClockContainer?.StopUsingBeatmapClock(); + (GameplayClockContainer as MasterGameplayClockContainer)?.StopUsingBeatmapClock(); musicController.ResetTrackAdjustments(); diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index cd30ead638..18ee73374f 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -21,8 +21,8 @@ namespace osu.Game.Screens.Play public bool AllowRestart { get; set; } = true; /// - /// Whether the player should be allowed to skip the intro, advancing to the start of gameplay. + /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard. /// - public bool AllowSkippingIntro { get; set; } = true; + public bool AllowSkipping { get; set; } = true; } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 3f214e49d9..ed49fc40b2 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -8,19 +8,19 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osu.Game.Screens.Ranking; using osuTK; using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Containers; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Framework.Utils; -using osu.Game.Input.Bindings; namespace osu.Game.Screens.Play { @@ -90,7 +90,19 @@ namespace osu.Game.Screens.Play private const double fade_time = 300; - private double fadeOutBeginTime => startTime - GameplayClockContainer.MINIMUM_SKIP_TIME; + private double fadeOutBeginTime => startTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME; + + public override void Hide() + { + base.Hide(); + fadeContainer.Hide(); + } + + public override void Show() + { + base.Show(); + fadeContainer.Show(); + } protected override void LoadComplete() { @@ -147,7 +159,7 @@ namespace osu.Game.Screens.Play { } - private class FadeContainer : Container, IStateful + public class FadeContainer : Container, IStateful { public event Action StateChanged; @@ -170,7 +182,7 @@ namespace osu.Game.Screens.Play switch (state) { case Visibility.Visible: - // we may be triggered to become visible mnultiple times but we only want to transform once. + // we may be triggered to become visible multiple times but we only want to transform once. if (stateChanged) this.FadeIn(500, Easing.OutExpo); diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index 6c7cb9376c..db81633aea 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -14,6 +14,7 @@ using osu.Framework.Timing; using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.Play { @@ -71,30 +72,38 @@ namespace osu.Game.Screens.Play public SongProgress() { - Masking = true; - Children = new Drawable[] { - info = new SongProgressInfo + new SongProgressDisplay { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = info_height, - }, - graph = new SongProgressGraph - { - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Height = graph_height, - Margin = new MarginPadding { Bottom = bottom_bar_height }, - }, - bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - OnSeek = time => RequestSeek?.Invoke(time), + Masking = true, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + info = new SongProgressInfo + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = info_height, + }, + graph = new SongProgressGraph + { + RelativeSizeAxes = Axes.X, + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + Height = graph_height, + Margin = new MarginPadding { Bottom = bottom_bar_height }, + }, + bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + OnSeek = time => RequestSeek?.Invoke(time), + }, + } }, }; } @@ -175,5 +184,11 @@ namespace osu.Game.Screens.Play float finalMargin = bottom_bar_height + (AllowSeeking.Value ? handle_size.Y : 0) + (ShowGraph.Value ? graph_height : 0); info.TransformTo(nameof(info.Margin), new MarginPadding { Bottom = finalMargin }, transition_duration, Easing.In); } + + public class SongProgressDisplay : Container, ISkinnableComponent + { + // TODO: move actual implementation into this. + // exists for skin customisation purposes. + } } } diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index fdf996150f..9822f62dd8 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -61,7 +61,7 @@ namespace osu.Game.Screens.Play if (firstFrameTime == null || firstFrameTime <= gameplayStart + 5000) return base.CreateGameplayClockContainer(beatmap, gameplayStart); - return new GameplayClockContainer(beatmap, firstFrameTime.Value, true); + return new MasterGameplayClockContainer(beatmap, firstFrameTime.Value, true); } public override bool OnExiting(IScreen next) diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 4069dc82ed..e1cf0cef4e 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -11,7 +11,6 @@ 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.Utils; using osu.Game.Beatmaps; @@ -29,6 +28,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Select { @@ -49,7 +49,9 @@ namespace osu.Game.Screens.Select private IBindable beatmapDifficulty; - protected BufferedWedgeInfo Info; + protected Container DisplayedContent { get; private set; } + + protected WedgeInfoText Info { get; private set; } public BeatmapInfoWedge() { @@ -110,9 +112,9 @@ namespace osu.Game.Screens.Select } } - public override bool IsPresent => base.IsPresent || Info == null; // Visibility is updated in the LoadComponentAsync callback + public override bool IsPresent => base.IsPresent || DisplayedContent == null; // Visibility is updated in the LoadComponentAsync callback - private BufferedWedgeInfo loadingInfo; + private Container loadingInfo; private void updateDisplay() { @@ -124,9 +126,9 @@ namespace osu.Game.Screens.Select { State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible; - Info?.FadeOut(250); - Info?.Expire(); - Info = null; + DisplayedContent?.FadeOut(250); + DisplayedContent?.Expire(); + DisplayedContent = null; } if (beatmap == null) @@ -135,17 +137,23 @@ namespace osu.Game.Screens.Select return; } - LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty()) + LoadComponentAsync(loadingInfo = new Container { + RelativeSizeAxes = Axes.Both, Shear = -Shear, - Depth = Info?.Depth + 1 ?? 0 + Depth = DisplayedContent?.Depth + 1 ?? 0, + Children = new Drawable[] + { + new BeatmapInfoWedgeBackground(beatmap), + Info = new WedgeInfoText(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty()), + } }, loaded => { // ensure we are the most recent loaded wedge. if (loaded != loadingInfo) return; removeOldInfo(); - Add(Info = loaded); + Add(DisplayedContent = loaded); }); } } @@ -156,7 +164,7 @@ namespace osu.Game.Screens.Select cancellationSource?.Cancel(); } - public class BufferedWedgeInfo : BufferedContainer + public class WedgeInfoText : Container { public OsuSpriteText VersionLabel { get; private set; } public OsuSpriteText TitleLabel { get; private set; } @@ -176,8 +184,7 @@ namespace osu.Game.Screens.Select private ModSettingChangeTracker settingChangeTracker; - public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty) - : base(pixelSnapping: true) + public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty) { this.beatmap = beatmap; ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; @@ -191,7 +198,6 @@ namespace osu.Game.Screens.Select var beatmapInfo = beatmap.BeatmapInfo; var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); - CacheDrawnFrameBuffer = true; RelativeSizeAxes = Axes.Both; titleBinding = localisation.GetLocalisedString(new RomanisableString(metadata.TitleUnicode, metadata.Title)); @@ -199,32 +205,6 @@ namespace osu.Game.Screens.Select Children = new Drawable[] { - // We will create the white-to-black gradient by modulating transparency and having - // a black backdrop. This results in an sRGB-space gradient and not linear space, - // transitioning from white to black more perceptually uniformly. - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - // We use a container, such that we can set the colour gradient to go across the - // vertices of the masked container instead of the vertices of the (larger) sprite. - new Container - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0.3f)), - Children = new[] - { - // Zoomed-in and cropped beatmap background - new BeatmapBackgroundSprite(beatmap) - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill, - }, - }, - }, new DifficultyColourBar(starDifficulty) { RelativeSizeAxes = Axes.Y, @@ -307,7 +287,7 @@ namespace osu.Game.Screens.Select Margin = new MarginPadding { Top = 10 }, Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Children = getMapper(metadata) + Child = getMapper(metadata), }, infoLabelContainer = new FillFlowContainer { @@ -340,7 +320,6 @@ namespace osu.Game.Screens.Select { ArtistLabel.Text = artistBinding.Value; TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value; - ForceRedraw(); } private void addInfoLabels() @@ -426,28 +405,22 @@ namespace osu.Game.Screens.Select CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), Content = labelText }); - - ForceRedraw(); } - private OsuSpriteText[] getMapper(BeatmapMetadata metadata) + private Drawable getMapper(BeatmapMetadata metadata) { - if (string.IsNullOrEmpty(metadata.Author?.Username)) - return Array.Empty(); + if (metadata.Author == null) + return Empty(); - return new[] + return new LinkFlowContainer(s => { - new OsuSpriteText - { - Text = "mapped by ", - Font = OsuFont.GetFont(size: 15), - }, - new OsuSpriteText - { - Text = metadata.Author.Username, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15), - } - }; + s.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.AddText("mapped by "); + d.AddUserLink(metadata.Author); + }); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs new file mode 100644 index 0000000000..f50fb4dc8a --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.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 osuTK.Graphics; +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.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.Screens.Select +{ + internal class BeatmapInfoWedgeBackground : CompositeDrawable + { + private readonly WorkingBeatmap beatmap; + + public BeatmapInfoWedgeBackground(WorkingBeatmap beatmap) + { + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new BufferedContainer + { + CacheDrawnFrameBuffer = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + // We will create the white-to-black gradient by modulating transparency and having + // a black backdrop. This results in an sRGB-space gradient and not linear space, + // transitioning from white to black more perceptually uniformly. + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + // We use a container, such that we can set the colour gradient to go across the + // vertices of the masked container instead of the vertices of the (larger) sprite. + new Container + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0.3f)), + Children = new[] + { + // Zoomed-in and cropped beatmap background + new BeatmapBackgroundSprite(beatmap) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + }, + }, + }, + } + }; + } + } +} diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 6dd3144fc8..ed01d56801 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -44,7 +44,6 @@ namespace osu.Game.Screens.Spectate private readonly object stateLock = new object(); private readonly Dictionary userMap = new Dictionary(); - private readonly Dictionary spectatorStates = new Dictionary(); private readonly Dictionary gameplayStates = new Dictionary(); private IBindable> managerUpdated; @@ -62,26 +61,42 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); - spectatorClient.OnUserBeganPlaying += userBeganPlaying; - spectatorClient.OnUserFinishedPlaying += userFinishedPlaying; - spectatorClient.OnNewFrames += userSentFrames; - - foreach (var id in userIds) + populateAllUsers().ContinueWith(_ => Schedule(() => { - userLookupCache.GetUserAsync(id).ContinueWith(u => Schedule(() => + spectatorClient.BindUserBeganPlaying(userBeganPlaying, true); + spectatorClient.OnUserFinishedPlaying += userFinishedPlaying; + spectatorClient.OnNewFrames += userSentFrames; + + managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); + + lock (stateLock) { - if (u.Result == null) + foreach (var (id, _) in userMap) + spectatorClient.WatchUser(id); + } + })); + } + + private Task populateAllUsers() + { + var userLookupTasks = new Task[userIds.Length]; + + for (int i = 0; i < userIds.Length; i++) + { + var userId = userIds[i]; + + userLookupTasks[i] = userLookupCache.GetUserAsync(userId).ContinueWith(task => + { + if (!task.IsCompletedSuccessfully) return; lock (stateLock) - userMap[id] = u.Result; - - spectatorClient.WatchUser(id); - }), TaskContinuationOptions.OnlyOnRanToCompletion); + userMap[userId] = task.Result; + }); } - managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); - managerUpdated.BindValueChanged(beatmapUpdated); + return Task.WhenAll(userLookupTasks); } private void beatmapUpdated(ValueChangedEvent> e) @@ -91,9 +106,12 @@ namespace osu.Game.Screens.Spectate lock (stateLock) { - foreach (var (userId, state) in spectatorStates) + foreach (var (userId, _) in userMap) { - if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID)) + if (!spectatorClient.TryGetPlayingUserState(userId, out var userState)) + continue; + + if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID)) updateGameplayState(userId); } } @@ -109,7 +127,10 @@ namespace osu.Game.Screens.Spectate if (!userMap.ContainsKey(userId)) return; - spectatorStates[userId] = state; + // The user may have stopped playing. + if (!spectatorClient.TryGetPlayingUserState(userId, out _)) + return; + Schedule(() => OnUserStateChanged(userId, state)); updateGameplayState(userId); @@ -122,7 +143,10 @@ namespace osu.Game.Screens.Spectate { Debug.Assert(userMap.ContainsKey(userId)); - var spectatorState = spectatorStates[userId]; + // The user may have stopped playing. + if (!spectatorClient.TryGetPlayingUserState(userId, out var spectatorState)) + return; + var user = userMap[userId]; var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance(); diff --git a/osu.Game/Skinning/Editor/SkinBlueprint.cs b/osu.Game/Skinning/Editor/SkinBlueprint.cs new file mode 100644 index 0000000000..11409c46ab --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinBlueprint.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning.Editor +{ + public class SkinBlueprint : SelectionBlueprint + { + private Container box; + + private Drawable drawable => (Drawable)Item; + + /// + /// Whether the blueprint should be shown even when the is not alive. + /// + protected virtual bool AlwaysShowWhenSelected => false; + + protected override bool ShouldBeAlive => (drawable.IsAlive && Item.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); + + public SkinBlueprint(ISkinnableComponent component) + : base(component) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + box = new Container + { + Colour = colours.Yellow, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.2f, + AlwaysPresent = true, + }, + } + }, + }; + } + + private Quad drawableQuad; + + public override Quad ScreenSpaceDrawQuad => drawableQuad; + + protected override void Update() + { + base.Update(); + + drawableQuad = drawable.ScreenSpaceDrawQuad; + var quad = ToLocalSpace(drawable.ScreenSpaceDrawQuad); + + box.Position = quad.TopLeft; + box.Size = quad.Size; + box.Rotation = drawable.Rotation; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => drawable.ReceivePositionalInputAt(screenSpacePos); + + public override Vector2 ScreenSpaceSelectionPoint => drawable.ScreenSpaceDrawQuad.Centre; + + public override Quad SelectionQuad => drawable.ScreenSpaceDrawQuad; + } +} diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs new file mode 100644 index 0000000000..d9bfefe5f2 --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Skinning.Editor +{ + public class SkinBlueprintContainer : BlueprintContainer + { + private readonly Drawable target; + + public SkinBlueprintContainer(Drawable target) + { + this.target = target; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + checkForComponents(); + } + + private void checkForComponents() + { + foreach (var c in target.ChildrenOfType().ToArray()) AddBlueprintFor(c); + + // We'd hope to eventually be running this in a more sensible way, but this handles situations where new drawables become present (ie. during ongoing gameplay) + // or when drawables in the target are loaded asynchronously and may not be immediately available when this BlueprintContainer is loaded. + Scheduler.AddDelayed(checkForComponents, 1000); + } + + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(ISkinnableComponent component) + => new SkinBlueprint(component); + } +} diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs new file mode 100644 index 0000000000..562dd23224 --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinEditor.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; + +namespace osu.Game.Skinning.Editor +{ + public class SkinEditor : FocusedOverlayContainer + { + public const double TRANSITION_DURATION = 500; + + private readonly Drawable target; + + private OsuTextFlowContainer headerText; + + protected override bool StartHidden => true; + + public SkinEditor(Drawable target) + { + this.target = target; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + headerText = new OsuTextFlowContainer + { + TextAnchor = Anchor.TopCentre, + Padding = new MarginPadding(20), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X + }, + new SkinBlueprintContainer(target), + } + }; + + headerText.AddParagraph("Skin editor (preview)", cp => cp.Font = OsuFont.Default.With(size: 24)); + headerText.AddParagraph("This is a preview of what is to come. Changes are lost on changing screens.", cp => + { + cp.Font = OsuFont.Default.With(size: 12); + cp.Colour = colours.Yellow; + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Show(); + } + + protected override bool OnHover(HoverEvent e) => true; + + protected override bool OnMouseDown(MouseDownEvent e) => true; + + protected override void PopIn() + { + this.FadeIn(TRANSITION_DURATION, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs new file mode 100644 index 0000000000..06c6dffb60 --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -0,0 +1,97 @@ +// 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.Input.Bindings; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; + +namespace osu.Game.Skinning.Editor +{ + /// + /// A container which handles loading a skin editor on user request for a specified target. + /// This also handles the scaling / positioning adjustment of the target. + /// + public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler + { + private readonly ScalingContainer target; + private SkinEditor skinEditor; + + private const float visible_target_scale = 0.8f; + + [Resolved] + private OsuColour colours { get; set; } + + public SkinEditorOverlay(ScalingContainer target) + { + this.target = target; + RelativeSizeAxes = Axes.Both; + } + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Back: + if (skinEditor?.State.Value == Visibility.Visible) + { + skinEditor.ToggleVisibility(); + return true; + } + + break; + + case GlobalAction.ToggleSkinEditor: + if (skinEditor == null) + { + LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); + skinEditor.State.BindValueChanged(editorVisibilityChanged); + } + else + skinEditor.ToggleVisibility(); + + return true; + } + + return false; + } + + private void editorVisibilityChanged(ValueChangedEvent visibility) + { + if (visibility.NewValue == Visibility.Visible) + { + target.ScaleTo(visible_target_scale, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + + target.Masking = true; + target.BorderThickness = 5; + target.BorderColour = colours.Yellow; + target.AllowScaling = false; + } + else + { + target.BorderThickness = 0; + target.AllowScaling = true; + + target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => target.Masking = false); + } + } + + public void OnReleased(GlobalAction action) + { + } + + /// + /// Exit any existing skin editor due to the game state changing. + /// + public void Reset() + { + skinEditor?.Hide(); + skinEditor?.Expire(); + skinEditor = null; + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs new file mode 100644 index 0000000000..d09ba8af0e --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.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 System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Extensions; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning.Editor +{ + public class SkinSelectionHandler : SelectionHandler + { + public override bool HandleRotation(float angle) + { + // TODO: this doesn't correctly account for origin/anchor specs being different in a multi-selection. + foreach (var c in SelectedBlueprints) + ((Drawable)c.Item).Rotation += angle; + + return base.HandleRotation(angle); + } + + public override bool HandleScale(Vector2 scale, Anchor anchor) + { + adjustScaleFromAnchor(ref scale, anchor); + + foreach (var c in SelectedBlueprints) + // TODO: this is temporary and will be fixed with a separate refactor of selection transform logic. + ((Drawable)c.Item).Scale += scale * 0.02f; + + return true; + } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) + { + foreach (var c in SelectedBlueprints) + { + Drawable drawable = (Drawable)c.Item; + drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + } + + return true; + } + + protected override void OnSelectionChanged() + { + base.OnSelectionChanged(); + + SelectionBox.CanRotate = true; + SelectionBox.CanScaleX = true; + SelectionBox.CanScaleY = true; + SelectionBox.CanReverse = false; + } + + protected override void DeleteItems(IEnumerable items) + { + foreach (var i in items) + { + ((Drawable)i).Expire(); + SelectedItems.Remove(i); + } + } + + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + { + yield return new OsuMenuItem("Anchor") + { + Items = createAnchorItems().ToArray() + }; + + foreach (var item in base.GetContextMenuItemsForSelection(selection)) + yield return item; + + IEnumerable createAnchorItems() + { + var displayableAnchors = new[] + { + Anchor.TopLeft, + Anchor.TopCentre, + Anchor.TopRight, + Anchor.CentreLeft, + Anchor.Centre, + Anchor.CentreRight, + Anchor.BottomLeft, + Anchor.BottomCentre, + Anchor.BottomRight, + }; + + return displayableAnchors.Select(a => + { + return new AnchorMenuItem(a, selection, _ => applyAnchor(a)) + { + State = { Value = GetStateFromSelection(selection, c => ((Drawable)c.Item).Anchor == a) } + }; + }); + } + } + + private void applyAnchor(Anchor anchor) + { + foreach (var item in SelectedItems) + ((Drawable)item).Anchor = anchor; + } + + private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) + { + // cancel out scale in axes we don't care about (based on which drag handle was used). + if ((reference & Anchor.x1) > 0) scale.X = 0; + if ((reference & Anchor.y1) > 0) scale.Y = 0; + + // reverse the scale direction if dragging from top or left. + if ((reference & Anchor.x0) > 0) scale.X = -scale.X; + if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y; + } + + public class AnchorMenuItem : TernaryStateMenuItem + { + public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action) + : base(anchor.ToString(), getNextState, MenuItemType.Standard, action) + { + } + + private static TernaryState getNextState(TernaryState state) => TernaryState.True; + } + } +} diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index 7d6f1dc916..c592e06924 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyAccuracyCounter : PercentageCounter, IAccuracyCounter + public class LegacyAccuracyCounter : PercentageCounter, IAccuracyCounter, ISkinnableComponent { private readonly ISkin skin; diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 2921d46467..2e29abf453 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay + public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay, ISkinnableComponent { private const double epic_cutoff = 0.5; diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index 1d330ef495..cae8044242 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -5,11 +5,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Play.HUD; using osuTK; namespace osu.Game.Skinning { - public class LegacyScoreCounter : ScoreCounter + public class LegacyScoreCounter : ScoreCounter, ISkinnableComponent { private readonly ISkin skin; diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index b3c7c5d6b2..10b8c47028 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -16,7 +16,7 @@ namespace osu.Game.Skinning { public double Length => !DrawableSamples.Any() ? 0 : DrawableSamples.Max(sample => sample.Length); - protected bool RequestedPlaying { get; private set; } + public bool RequestedPlaying { get; private set; } public PausableSkinnableSound() { diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index af4615c895..4c42823779 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using osuTK; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; @@ -19,6 +20,13 @@ namespace osu.Game.Storyboards.Drawables [Cached] public Storyboard Storyboard { get; } + /// + /// Whether the storyboard is considered finished. + /// + public IBindable HasStoryboardEnded => hasStoryboardEnded; + + private readonly BindableBool hasStoryboardEnded = new BindableBool(); + protected override Container Content { get; } protected override Vector2 DrawScale => new Vector2(Parent.DrawHeight / 480); @@ -39,6 +47,8 @@ namespace osu.Game.Storyboards.Drawables public override bool RemoveCompletedTransforms => false; + private double? lastEventEndTime; + private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => @@ -73,6 +83,14 @@ namespace osu.Game.Storyboards.Drawables Add(layer.CreateDrawable()); } + + lastEventEndTime = Storyboard.LatestEventTime; + } + + protected override void Update() + { + base.Update(); + hasStoryboardEnded.Value = lastEventEndTime == null || Time.Current >= lastEventEndTime; } public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay"); diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index 7b16009859..fbdd27e762 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -61,28 +61,32 @@ namespace osu.Game.Storyboards.Drawables { base.Update(); + // Check if we've yet to pass the sample start time. if (Time.Current < sampleInfo.StartTime) { - // We've rewound before the start time of the sample Stop(); - // In the case that the user fast-forwards to a point far beyond the start time of the sample, - // we want to be able to fall into the if-conditional below (therefore we must not have a life time end) + // Playback has stopped, but if the user fast-forwards to a point after the start time of the sample then + // we must not have a lifetime end in order to continue receiving updates and start the sample below. LifetimeStart = sampleInfo.StartTime; LifetimeEnd = double.MaxValue; + + return; } - else if (Time.Current - Time.Elapsed <= sampleInfo.StartTime) + + // Ensure that we've elapsed from a point before the sample's start time before playing. + if (Time.Current - Time.Elapsed <= sampleInfo.StartTime) { // We've passed the start time of the sample. We only play the sample if we're within an allowable range // from the sample's start, to reduce layering if we've been fast-forwarded far into the future if (!RequestedPlaying && Time.Current - sampleInfo.StartTime < allowable_late_start) Play(); - - // In the case that the user rewinds to a point far behind the start time of the sample, - // we want to be able to fall into the if-conditional above (therefore we must not have a life time start) - LifetimeStart = double.MinValue; - LifetimeEnd = sampleInfo.StartTime; } + + // Playback has started, but if the user rewinds to a point before the start time of the sample then + // we must not have a lifetime start in order to continue receiving updates and stop the sample above. + LifetimeStart = double.MinValue; + LifetimeEnd = sampleInfo.StartTime; } } } diff --git a/osu.Game/Storyboards/IStoryboardElement.cs b/osu.Game/Storyboards/IStoryboardElement.cs index c4c150a8a4..9a059991e6 100644 --- a/osu.Game/Storyboards/IStoryboardElement.cs +++ b/osu.Game/Storyboards/IStoryboardElement.cs @@ -14,4 +14,17 @@ namespace osu.Game.Storyboards Drawable CreateDrawable(); } + + public static class StoryboardElementExtensions + { + /// + /// Returns the end time of this storyboard element. + /// + /// + /// This returns the where available, falling back to otherwise. + /// + /// The storyboard element. + /// The end time of this element. + public static double GetEndTime(this IStoryboardElement element) => (element as IStoryboardElementWithDuration)?.EndTime ?? element.StartTime; + } } diff --git a/osu.Game/Storyboards/IStoryboardElementWithDuration.cs b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs new file mode 100644 index 0000000000..55f163ee07 --- /dev/null +++ b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Storyboards +{ + /// + /// A that ends at a different time than its start time. + /// + public interface IStoryboardElementWithDuration : IStoryboardElement + { + /// + /// The time at which the ends. + /// + double EndTime { get; } + + /// + /// The duration of the StoryboardElement. + /// + double Duration => EndTime - StartTime; + } +} diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 1ba25cc11e..bc61f704dd 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -36,6 +36,16 @@ namespace osu.Game.Storyboards /// public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.StartTime).FirstOrDefault()?.StartTime; + /// + /// Across all layers, find the latest point in time that a storyboard element ends at. + /// Will return null if there are no elements. + /// + /// + /// This iterates all elements and as such should be used sparingly or stored locally. + /// Videos and samples return StartTime as their EndTIme. + /// + public double? LatestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.GetEndTime()).LastOrDefault()?.GetEndTime(); + /// /// Depth of the currently front-most storyboard layer, excluding the overlay layer. /// diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index fdaa59d7d9..bf87e7d10e 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -11,7 +11,7 @@ using JetBrains.Annotations; namespace osu.Game.Storyboards { - public class StoryboardSprite : IStoryboardElement + public class StoryboardSprite : IStoryboardElementWithDuration { private readonly List loops = new List(); private readonly List triggers = new List(); diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 5ef2458919..1c5e551042 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -215,6 +215,8 @@ namespace osu.Game.Tests.Beatmaps protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); + protected override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) { var converter = base.CreateBeatmapConverter(beatmap, ruleset); diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index bfcb2403c1..852006bc9b 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.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.IO; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; @@ -35,6 +36,8 @@ namespace osu.Game.Tests.Beatmaps protected override Storyboard GetStoryboard() => storyboard ?? base.GetStoryboard(); + public override Stream GetStream(string storagePath) => null; + protected override Texture GetBackground() => null; protected override Track GetBeatmapTrack() => null; diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 79cfee8518..34393fba7d 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Screens.Edit; @@ -46,7 +45,7 @@ namespace osu.Game.Tests.Visual private void beatmapChanged(ValueChangedEvent e) { Clock.ControlPointInfo = e.NewValue.Beatmap.ControlPointInfo; - Clock.ChangeSource((IAdjustableClock)e.NewValue.Track ?? new StopwatchClock()); + Clock.ChangeSource(e.NewValue.Track); Clock.ProcessFrame(); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index b5cd3dad02..de77a15da0 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -25,6 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); + public Action? RoomSetupAction; + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -112,7 +114,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == roomId); - var user = new MultiplayerRoomUser(api.LocalUser.Value.Id) + var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value }; @@ -129,10 +131,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AllowedMods = apiRoom.Playlist.Last().AllowedMods.Select(m => new APIMod(m)).ToArray(), PlaylistItemId = apiRoom.Playlist.Last().ID }, - Users = { user }, - Host = user + Users = { localUser }, + Host = localUser }; + RoomSetupAction?.Invoke(room); + RoomSetupAction = null; + return Task.FromResult(room); } diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index 74ffb7c457..2e04693e82 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -144,9 +144,15 @@ namespace osu.Game.Users [JsonProperty(@"unranked_beatmapset_count")] public int UnrankedBeatmapsetCount; + [JsonProperty(@"scores_best_count")] + public int ScoresBestCount; + [JsonProperty(@"scores_first_count")] public int ScoresFirstCount; + [JsonProperty(@"scores_recent_count")] + public int ScoresRecentCount; + [JsonProperty(@"beatmap_playcounts_count")] public int BeatmapPlaycountsCount; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 509cb3ddad..29189781a7 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -21,19 +21,19 @@ - - - + + + - - - - - + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index 4b67bd78a1..c4eb7aefba 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -93,9 +93,9 @@ - - - + + +